mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-07-21 04:01:07 +02:00
Compare commits
41 Commits
v0.6.7
...
17fd211740
Author | SHA1 | Date | |
---|---|---|---|
|
17fd211740 | ||
|
3b83a8a1e2 | ||
|
13e033e4a2 | ||
|
55b66393c3 | ||
|
b495c70206 | ||
|
a9fa720577 | ||
|
257d97ae0f | ||
|
7193480796 | ||
|
788d0f0325 | ||
|
935025eacc | ||
|
358d041c0d | ||
|
49a8114843 | ||
|
c0c3a387bb | ||
|
5ce74b8198 | ||
|
0b2602b460 | ||
|
f9bc334228 | ||
|
55732afa71 | ||
|
22372f58a4 | ||
|
e990284bd1 | ||
|
7850e53b61 | ||
|
68bafaceaa | ||
|
0b1cd1b11a | ||
|
646cb4ea48 | ||
|
03978cc37a | ||
|
048aaa45a7 | ||
|
3ff2d7ccce | ||
|
6a7a731c5d | ||
|
4da5ac262c | ||
|
e471031f19 | ||
|
34b9851b36 | ||
|
8ede2a2f39 | ||
|
df008464cc | ||
|
e99adaac03 | ||
|
8a40170c89 | ||
|
2f3113f0f6 | ||
|
c9528ff85a | ||
|
e7a87897f5 | ||
|
952ac336bf | ||
|
7e92b81711 | ||
|
ac4adf5234 | ||
|
5f68795a82 |
55
.github/workflows/test_and_deploy.yml
vendored
55
.github/workflows/test_and_deploy.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [ubuntu-latest, windows-latest, macos-13]
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
||||
backend: [pyqt5, pyside2, pyqt6]
|
||||
exclude:
|
||||
# Abort (core dumped) on linux pyqt6, unknown reason
|
||||
@@ -36,39 +36,48 @@ jobs:
|
||||
# lack of wheels for pyside2/py3.11
|
||||
- python-version: "3.11"
|
||||
backend: pyside2
|
||||
include:
|
||||
- python-version: "3.10"
|
||||
platform: macos-latest
|
||||
backend: pyside6
|
||||
- python-version: "3.11"
|
||||
platform: macos-latest
|
||||
backend: pyside6
|
||||
- python-version: "3.10"
|
||||
platform: windows-latest
|
||||
backend: pyside6
|
||||
- python-version: "3.11"
|
||||
platform: windows-latest
|
||||
backend: pyside6
|
||||
- python-version: "3.12"
|
||||
backend: pyside2
|
||||
- python-version: "3.12"
|
||||
backend: pyqt5
|
||||
include:
|
||||
- python-version: "3.13"
|
||||
platform: windows-latest
|
||||
backend: "pyqt6"
|
||||
- python-version: "3.13"
|
||||
platform: ubuntu-latest
|
||||
backend: "pyqt6"
|
||||
|
||||
- python-version: "3.10"
|
||||
platform: macos-latest
|
||||
backend: pyqt6
|
||||
backend: "'pyside6<6.8'"
|
||||
- python-version: "3.11"
|
||||
platform: macos-latest
|
||||
backend: "'pyside6<6.8'"
|
||||
- python-version: "3.10"
|
||||
platform: windows-latest
|
||||
backend: "'pyside6<6.8'"
|
||||
- python-version: "3.12"
|
||||
platform: windows-latest
|
||||
backend: "'pyside6<6.8'"
|
||||
|
||||
# legacy Qt
|
||||
- python-version: 3.8
|
||||
- python-version: 3.9
|
||||
platform: ubuntu-latest
|
||||
backend: "pyqt5==5.12.*"
|
||||
- python-version: 3.8
|
||||
- python-version: 3.9
|
||||
platform: ubuntu-latest
|
||||
backend: "pyqt5==5.13.*"
|
||||
- python-version: 3.8
|
||||
- python-version: 3.9
|
||||
platform: ubuntu-latest
|
||||
backend: "pyqt5==5.14.*"
|
||||
|
||||
test-qt-minreqs:
|
||||
uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2
|
||||
with:
|
||||
python-version: "3.8"
|
||||
python-version: "3.9"
|
||||
qt: pyqt5
|
||||
pip-post-installs: "qtpy==1.1.0 typing-extensions==3.7.4.3"
|
||||
pip-post-installs: "qtpy==1.1.0 typing-extensions==4.5.0" # 4.5.0 is just for pint
|
||||
pip-install-flags: -e
|
||||
coverage-upload: artifact
|
||||
|
||||
@@ -85,14 +94,14 @@ jobs:
|
||||
dependency-ref: ${{ matrix.napari-version }}
|
||||
dependency-extras: "testing"
|
||||
qt: ${{ matrix.qt }}
|
||||
pytest-args: 'napari/_qt -k "not async and not qt_dims_2 and not qt_viewer_console_focus and not keybinding_editor"'
|
||||
pytest-args: 'src/napari/_qt --import-mode=importlib -k "not async and not qt_dims_2 and not qt_viewer_console_focus and not keybinding_editor and not preferences_dialog_not_dismissed"'
|
||||
python-version: "3.10"
|
||||
post-install-cmd: "pip install lxml_html_clean"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
napari-version: ["", "v0.4.19.post1"]
|
||||
qt: ["pyqt5", "pyside2"]
|
||||
napari-version: [ "" ]
|
||||
qt: [ "pyqt5", "pyside2" ]
|
||||
|
||||
check-manifest:
|
||||
name: Check Manifest
|
||||
|
@@ -5,19 +5,19 @@ ci:
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.4.7
|
||||
rev: v0.12.3
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --unsafe-fixes]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/abravalheri/validate-pyproject
|
||||
rev: v0.18
|
||||
rev: v0.24.1
|
||||
hooks:
|
||||
- id: validate-pyproject
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.10.0
|
||||
rev: v1.17.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
exclude: tests|examples
|
||||
|
102
CHANGELOG.md
102
CHANGELOG.md
@@ -1,5 +1,107 @@
|
||||
# Changelog
|
||||
|
||||
## [v0.7.5](https://github.com/pyapp-kit/superqt/tree/v0.7.5) (2025-06-18)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.7.4...v0.7.5)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: Use scientific notation for big values in labeled slider [\#226](https://github.com/pyapp-kit/superqt/pull/226) ([Czaki](https://github.com/Czaki))
|
||||
|
||||
## [v0.7.4](https://github.com/pyapp-kit/superqt/tree/v0.7.4) (2025-06-10)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.7.3...v0.7.4)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: Allow setting label position on labeled slider [\#294](https://github.com/pyapp-kit/superqt/pull/294) ([brisvag](https://github.com/brisvag))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: Set SliderProxy range params to Any [\#290](https://github.com/pyapp-kit/superqt/pull/290) ([gselzer](https://github.com/gselzer))
|
||||
- Make qimage\_to\_array\(\) work on big endian [\#288](https://github.com/pyapp-kit/superqt/pull/288) ([penguinpee](https://github.com/penguinpee))
|
||||
|
||||
**Documentation updates:**
|
||||
|
||||
- docs: document QToggleSwitch [\#286](https://github.com/pyapp-kit/superqt/pull/286) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Tests & CI:**
|
||||
|
||||
- Fix napari test [\#295](https://github.com/pyapp-kit/superqt/pull/295) ([brisvag](https://github.com/brisvag))
|
||||
|
||||
## [v0.7.3](https://github.com/pyapp-kit/superqt/tree/v0.7.3) (2025-03-28)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.7.2...v0.7.3)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: toggle switch [\#284](https://github.com/pyapp-kit/superqt/pull/284) ([hanjinliu](https://github.com/hanjinliu))
|
||||
- Enh: Adds a filterable kwarg to ColormapComboBox enabling filtering \(like Catalog\) [\#278](https://github.com/pyapp-kit/superqt/pull/278) ([psobolewskiPhD](https://github.com/psobolewskiPhD))
|
||||
|
||||
## [v0.7.2](https://github.com/pyapp-kit/superqt/tree/v0.7.2) (2025-03-17)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.7.1...v0.7.2)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- fix: less Slider signal renaming, make alternate signal types public [\#283](https://github.com/pyapp-kit/superqt/pull/283) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- ci: \[pre-commit.ci\] autoupdate [\#282](https://github.com/pyapp-kit/superqt/pull/282) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||
- ci: \[pre-commit.ci\] autoupdate [\#279](https://github.com/pyapp-kit/superqt/pull/279) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||
- Update CONTRIBUTING.md to include \[test\] and mention Qt backend [\#276](https://github.com/pyapp-kit/superqt/pull/276) ([psobolewskiPhD](https://github.com/psobolewskiPhD))
|
||||
- Update CONTRIBUTING.md to install .\[dev\] first then pre-commit [\#275](https://github.com/pyapp-kit/superqt/pull/275) ([psobolewskiPhD](https://github.com/psobolewskiPhD))
|
||||
- ci: \[pre-commit.ci\] autoupdate [\#272](https://github.com/pyapp-kit/superqt/pull/272) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||
|
||||
## [v0.7.1](https://github.com/pyapp-kit/superqt/tree/v0.7.1) (2025-01-05)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.7.0...v0.7.1)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: add QFlowLayout, for variable width widgets [\#271](https://github.com/pyapp-kit/superqt/pull/271) ([tlambert03](https://github.com/tlambert03))
|
||||
- feat: Improve CodeSyntaxHighlight object [\#268](https://github.com/pyapp-kit/superqt/pull/268) ([tlambert03](https://github.com/tlambert03))
|
||||
- feat: allow chaining of QIconifyIcon.addKey [\#267](https://github.com/pyapp-kit/superqt/pull/267) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: better warning for download error [\#266](https://github.com/pyapp-kit/superqt/pull/266) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- Lazy-import `pyconify` [\#270](https://github.com/pyapp-kit/superqt/pull/270) ([hanjinliu](https://github.com/hanjinliu))
|
||||
|
||||
## [v0.7.0](https://github.com/pyapp-kit/superqt/tree/v0.7.0) (2024-12-14)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.8...v0.7.0)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: End painter when drawing colormap [\#262](https://github.com/pyapp-kit/superqt/pull/262) ([gselzer](https://github.com/gselzer))
|
||||
- fix: minimum size hint for QElidingLabel [\#260](https://github.com/pyapp-kit/superqt/pull/260) ([gselzer](https://github.com/gselzer))
|
||||
- fix: KeyError in CodeSyntaxHighlight [\#258](https://github.com/pyapp-kit/superqt/pull/258) ([hanjinliu](https://github.com/hanjinliu))
|
||||
|
||||
**Refactors:**
|
||||
|
||||
- chore: Revert "remove stylesheet on sliderLabel \(\#254\)" [\#265](https://github.com/pyapp-kit/superqt/pull/265) ([tlambert03](https://github.com/tlambert03))
|
||||
- refactor: remove stylesheet on sliderLabel [\#254](https://github.com/pyapp-kit/superqt/pull/254) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- build: support py313 [\#264](https://github.com/pyapp-kit/superqt/pull/264) ([tlambert03](https://github.com/tlambert03))
|
||||
- build: drop py38 [\#263](https://github.com/pyapp-kit/superqt/pull/263) ([tlambert03](https://github.com/tlambert03))
|
||||
- ci: \[pre-commit.ci\] autoupdate [\#257](https://github.com/pyapp-kit/superqt/pull/257) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||
- ci: \[pre-commit.ci\] autoupdate [\#253](https://github.com/pyapp-kit/superqt/pull/253) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||
|
||||
## [v0.6.8](https://github.com/pyapp-kit/superqt/tree/v0.6.8) (2024-06-15)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.7...v0.6.8)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: graceful offline fallback for qiconify [\#251](https://github.com/pyapp-kit/superqt/pull/251) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.6.7](https://github.com/pyapp-kit/superqt/tree/v0.6.7) (2024-06-07)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.6...v0.6.7)
|
||||
|
@@ -12,12 +12,12 @@ To get started fork this repository, and clone your fork:
|
||||
git clone https://github.com/<your_organization>/superqt
|
||||
cd superqt
|
||||
|
||||
# install in editable mode (this will install PyQt6 as the Qt backend)
|
||||
pip install -e .[dev]
|
||||
|
||||
# install pre-commit hooks
|
||||
pre-commit install
|
||||
|
||||
# install in editable mode
|
||||
pip install -e .[dev]
|
||||
|
||||
# run tests & make sure everything is working!
|
||||
pytest
|
||||
```
|
||||
@@ -26,7 +26,7 @@ pytest
|
||||
|
||||
All widgets must be well-tested, and should work on:
|
||||
|
||||
- Python 3.8 and above
|
||||
- Python 3.9 and above
|
||||
- PyQt5 (5.11 and above) & PyQt6
|
||||
- PySide2 (5.11 and above) & PySide6
|
||||
- macOS, Windows, & Linux
|
||||
|
@@ -15,7 +15,7 @@ that are not provided in the native QtWidgets module.
|
||||
Components are tested on:
|
||||
|
||||
- macOS, Windows, & Linux
|
||||
- Python 3.8 and above
|
||||
- Python 3.9 and above
|
||||
- PyQt5 (5.11 and above) & PyQt6
|
||||
- PySide2 (5.11 and above) & PySide6
|
||||
|
||||
|
@@ -35,7 +35,7 @@ def define_env(env: "MacrosPlugin"):
|
||||
src = src.replace(
|
||||
"QApplication([])", "QApplication.instance() or QApplication([])"
|
||||
)
|
||||
src = src.replace("app.exec_()", "")
|
||||
src = src.replace("app.exec_()", "app.processEvents()")
|
||||
|
||||
exec(src)
|
||||
_grab(dest, width)
|
||||
@@ -127,7 +127,6 @@ def define_env(env: "MacrosPlugin"):
|
||||
|
||||
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]
|
||||
@@ -135,12 +134,3 @@ def _grab(dest: str | Path, width) -> list[Path]:
|
||||
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
|
||||
|
@@ -10,7 +10,7 @@ QtWidgets module.
|
||||
Components are tested on:
|
||||
|
||||
- macOS, Windows, & Linux
|
||||
- Python 3.8 and above
|
||||
- Python 3.9 and above
|
||||
- PyQt5 (5.11 and above) & PyQt6
|
||||
- PySide2 (5.11 and above) & PySide6
|
||||
|
||||
|
36
docs/utilities/iconify.md
Normal file
36
docs/utilities/iconify.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# QIconifyIcon
|
||||
|
||||
[Iconify](https://iconify.design/) is an icon library that includes 150,000+
|
||||
icons from most major icon sets including Bootstrap, FontAwesome, Material
|
||||
Design, and many more; each available as individual SVGs. Unlike the
|
||||
[`superqt.fonticon` module](./fonticon.md), `superqt.QIconifyIcon` does not require any additional
|
||||
dependencies or font files to be installed. Icons are downloaded (and cached)
|
||||
on-demand from the Iconify API, using [pyconify](https://github.com/pyapp-kit/pyconify)
|
||||
|
||||
Search availble icons at <https://icon-sets.iconify.design>
|
||||
Once you find one you like, use the key in the format `"prefix:name"` to create an
|
||||
icon: `QIconifyIcon("bi:bell")`.
|
||||
|
||||
## Basic Example
|
||||
|
||||
```python
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from superqt import QIconifyIcon
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
btn = QPushButton()
|
||||
btn.setIcon(QIconifyIcon("fluent-emoji-flat:alarm-clock"))
|
||||
btn.setIconSize(QSize(60, 60))
|
||||
btn.show()
|
||||
|
||||
app.exec()
|
||||
```
|
||||
|
||||
{{ show_widget(225) }}
|
||||
|
||||
::: superqt.QIconifyIcon
|
||||
options:
|
||||
heading_level: 3
|
@@ -12,6 +12,12 @@
|
||||
| [`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. |
|
||||
|
||||
## SVG Icons
|
||||
|
||||
| Object | Description |
|
||||
| ----------- | --------------------- |
|
||||
| [`QIconifyIcon`](./iconify.md) | QIcons backed by the [Iconify](https://iconify.design/) icon library. |
|
||||
|
||||
## Threading tools
|
||||
|
||||
| Object | Description |
|
||||
|
@@ -11,7 +11,7 @@ running in the desired 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)).
|
||||
details](https://doc.qt.io/qt-6/threads-qobject.html#accessing-qobject-subclasses-from-other-threads)).
|
||||
|
||||
## Usage
|
||||
|
||||
|
@@ -27,9 +27,11 @@ The following are QWidget subclasses:
|
||||
| [`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. |
|
||||
| [`QToggleSwitch`](./qtoggleswitch.md) | `QAbstractButton` that represents a boolean value with a toggle switch. |
|
||||
|
||||
## Frames and containers
|
||||
|
||||
| Widget | Description |
|
||||
| ----------- | --------------------- |
|
||||
| [`QCollapsible`](./qcollapsible.md) | A collapsible widget to hide and unhide child widgets. |
|
||||
| [`QFlowLayout`](./qflowlayout.md) | A layout that rearranges items based on parent width. |
|
||||
|
@@ -1,7 +1,7 @@
|
||||
# QEnumComboBox
|
||||
|
||||
`QEnumComboBox` is a variant of
|
||||
[`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) that populates the items in
|
||||
[`QComboBox`](https://doc.qt.io/qt-6/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
|
||||
|
29
docs/widgets/qflowlayout.md
Normal file
29
docs/widgets/qflowlayout.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# QFlowLayout
|
||||
|
||||
QLayout that rearranges items based on parent width.
|
||||
|
||||
```python
|
||||
from qtpy.QtWidgets import QApplication, QPushButton, QWidget
|
||||
|
||||
from superqt import QFlowLayout
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
wdg = QWidget()
|
||||
|
||||
layout = QFlowLayout(wdg)
|
||||
layout.addWidget(QPushButton("Short"))
|
||||
layout.addWidget(QPushButton("Longer"))
|
||||
layout.addWidget(QPushButton("Different text"))
|
||||
layout.addWidget(QPushButton("More text"))
|
||||
layout.addWidget(QPushButton("Even longer button text"))
|
||||
|
||||
wdg.setWindowTitle("Flow Layout")
|
||||
wdg.show()
|
||||
|
||||
app.exec()
|
||||
```
|
||||
|
||||
{{ show_widget(350) }}
|
||||
|
||||
{{ show_members('superqt.QFlowLayout') }}
|
@@ -20,7 +20,7 @@ app.exec_()
|
||||
|
||||
{{ show_widget() }}
|
||||
|
||||
- `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-5/qslider.html)
|
||||
- `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-6/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.
|
||||
@@ -28,9 +28,9 @@ app.exec_()
|
||||
- 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
|
||||
[`QtWidgets.QSlider`](https://doc.qt.io/qt-6/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()`
|
||||
API](https://doc.qt.io/qt-6/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.)
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
# QSearchableComboBox
|
||||
|
||||
`QSearchableComboBox` is a variant of
|
||||
[`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) that allow to filter list
|
||||
[`QComboBox`](https://doc.qt.io/qt-6/qcombobox.html) that allow to filter list
|
||||
of options by enter part of text. It could be drop in replacement for
|
||||
`QComboBox`.
|
||||
|
||||
|
@@ -1,13 +1,13 @@
|
||||
# QSearchableListWidget
|
||||
|
||||
`QSearchableListWidget` is a variant of
|
||||
[`QListWidget`](https://doc.qt.io/qt-5/qlistwidget.html) that add text entry
|
||||
[`QListWidget`](https://doc.qt.io/qt-6/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
|
||||
[`QListWidget`](https://doc.qt.io/qt-6/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.
|
||||
[`QListWidgetItem`](https://doc.qt.io/qt-6/qlistwidgetitem.html) constructor.
|
||||
|
||||
```python
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
24
docs/widgets/qtoggleswitch.md
Normal file
24
docs/widgets/qtoggleswitch.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# QToggleSwitch
|
||||
|
||||
`QToggleSwitch` is a
|
||||
[`QAbstractButton`](https://doc.qt.io/qt-6/qabstractbutton.html) subclass
|
||||
that represents a boolean value as a toggle switch. The API is similar to
|
||||
[`QCheckBox`](https://doc.qt.io/qt-6/qcheckbox.html) but with a different
|
||||
visual representation.
|
||||
|
||||
```python
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QToggleSwitch
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
switch = QToggleSwitch()
|
||||
switch.show()
|
||||
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
{{ show_widget(80) }}
|
||||
|
||||
{{ show_members('superqt.QToggleSwitch') }}
|
@@ -6,7 +6,7 @@ from qtpy import QtWidgets as QtW
|
||||
# patch for Qt 5.15 on macos >= 12
|
||||
os.environ["USE_MAC_SLIDER_PATCH"] = "1"
|
||||
|
||||
from superqt import QRangeSlider # noqa
|
||||
from superqt import QRangeSlider
|
||||
|
||||
QSS = """
|
||||
QSlider {
|
||||
|
19
examples/flow_layout.py
Normal file
19
examples/flow_layout.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from qtpy.QtWidgets import QApplication, QPushButton, QWidget
|
||||
|
||||
from superqt import QFlowLayout
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
wdg = QWidget()
|
||||
|
||||
layout = QFlowLayout(wdg)
|
||||
layout.addWidget(QPushButton("Short"))
|
||||
layout.addWidget(QPushButton("Longer"))
|
||||
layout.addWidget(QPushButton("Different text"))
|
||||
layout.addWidget(QPushButton("More text"))
|
||||
layout.addWidget(QPushButton("Even longer button text"))
|
||||
|
||||
wdg.setWindowTitle("Flow Layout")
|
||||
wdg.show()
|
||||
|
||||
app.exec()
|
@@ -27,11 +27,12 @@ qlds.setValue(0.5)
|
||||
qlds.setSingleStep(0.1)
|
||||
|
||||
qlrs = QLabeledRangeSlider(ORIENTATION)
|
||||
qlrs.valueChanged.connect(lambda e: print("QLabeledRangeSlider valueChanged", e))
|
||||
qlrs.setValue((20, 60))
|
||||
qlrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e))
|
||||
qlrs.setRange(0, 10**11)
|
||||
qlrs.setValue((20, 60 * 10**9))
|
||||
|
||||
qldrs = QLabeledDoubleRangeSlider(ORIENTATION)
|
||||
qldrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e))
|
||||
qldrs.valueChanged.connect(lambda e: print("qldrs valueChanged", e))
|
||||
qldrs.setRange(0, 1)
|
||||
qldrs.setSingleStep(0.01)
|
||||
qldrs.setValue((0.2, 0.7))
|
||||
|
@@ -27,7 +27,7 @@ SOFTWARE.
|
||||
|
||||
"""
|
||||
|
||||
from typing import Deque
|
||||
from collections import deque
|
||||
|
||||
from qtpy.QtCore import QRect, QSize, Qt, QTimer, Signal
|
||||
from qtpy.QtGui import QPainter, QPen
|
||||
@@ -65,8 +65,8 @@ class DrawSignalsWidget(QWidget):
|
||||
self._scrollTimer.timeout.connect(self._scroll)
|
||||
self._scrollTimer.start()
|
||||
|
||||
self._signalActivations: Deque[int] = Deque()
|
||||
self._throttledSignalActivations: Deque[int] = Deque()
|
||||
self._signalActivations: deque[int] = deque()
|
||||
self._throttledSignalActivations: deque[int] = deque()
|
||||
|
||||
def sizeHint(self):
|
||||
return QSize(400, 200)
|
||||
@@ -84,7 +84,7 @@ class DrawSignalsWidget(QWidget):
|
||||
|
||||
self.update()
|
||||
|
||||
def scrollAndCut(self, v: Deque[int], cutoff: int):
|
||||
def scrollAndCut(self, v: deque[int], cutoff: int):
|
||||
L = len(v)
|
||||
for p in range(L):
|
||||
v[p] += 1
|
||||
@@ -121,7 +121,7 @@ class DrawSignalsWidget(QWidget):
|
||||
p.drawLine(0, h2, w, h2)
|
||||
p.restore()
|
||||
|
||||
def _drawSignals(self, p: QPainter, v: Deque[int], color, yStart, yEnd):
|
||||
def _drawSignals(self, p: QPainter, v: deque[int], color, yStart, yEnd):
|
||||
p.save()
|
||||
pen = QPen()
|
||||
pen.setWidthF(2.0)
|
||||
|
67
examples/toggle_switch.py
Normal file
67
examples/toggle_switch.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from qtpy import QtCore, QtGui
|
||||
from qtpy.QtWidgets import QApplication, QStyle, QVBoxLayout, QWidget
|
||||
|
||||
from superqt import QToggleSwitch
|
||||
from superqt.switch import QStyleOptionToggleSwitch
|
||||
|
||||
QSS_EXAMPLE = """
|
||||
QToggleSwitch {
|
||||
qproperty-onColor: red;
|
||||
qproperty-handleSize: 12;
|
||||
qproperty-switchWidth: 30;
|
||||
qproperty-switchHeight: 16;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class QRectangleToggleSwitch(QToggleSwitch):
|
||||
"""A rectangle shaped toggle switch."""
|
||||
|
||||
def drawGroove(
|
||||
self,
|
||||
painter: QtGui.QPainter,
|
||||
rect: QtCore.QRectF,
|
||||
option: QStyleOptionToggleSwitch,
|
||||
) -> None:
|
||||
"""Draw the groove of the switch."""
|
||||
painter.setPen(QtCore.Qt.PenStyle.NoPen)
|
||||
is_checked = option.state & QStyle.StateFlag.State_On
|
||||
painter.setBrush(option.on_color if is_checked else option.off_color)
|
||||
painter.setOpacity(0.8)
|
||||
painter.drawRect(rect)
|
||||
|
||||
def drawHandle(self, painter, rect, option):
|
||||
"""Draw the handle of the switch."""
|
||||
painter.drawRect(rect)
|
||||
|
||||
|
||||
class QToggleSwitchWithText(QToggleSwitch):
|
||||
"""A toggle switch with text on the handle."""
|
||||
|
||||
def drawHandle(
|
||||
self,
|
||||
painter: QtGui.QPainter,
|
||||
rect: QtCore.QRectF,
|
||||
option: QStyleOptionToggleSwitch,
|
||||
) -> None:
|
||||
super().drawHandle(painter, rect, option)
|
||||
|
||||
text = "ON" if option.state & QStyle.StateFlag.State_On else "OFF"
|
||||
painter.setPen(QtGui.QPen(QtGui.QColor("black")))
|
||||
font = painter.font()
|
||||
font.setPointSize(5)
|
||||
painter.setFont(font)
|
||||
painter.drawText(rect, QtCore.Qt.AlignmentFlag.AlignCenter, text)
|
||||
|
||||
|
||||
app = QApplication([])
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout(widget)
|
||||
layout.addWidget(QToggleSwitch("original"))
|
||||
switch_styled = QToggleSwitch("stylesheet")
|
||||
switch_styled.setStyleSheet(QSS_EXAMPLE)
|
||||
layout.addWidget(switch_styled)
|
||||
layout.addWidget(QRectangleToggleSwitch("rectangle"))
|
||||
layout.addWidget(QToggleSwitchWithText("with text"))
|
||||
widget.show()
|
||||
app.exec()
|
@@ -32,8 +32,8 @@ markdown_extensions:
|
||||
- attr_list
|
||||
- md_in_html
|
||||
- pymdownx.emoji:
|
||||
emoji_index: !!python/name:materialx.emoji.twemoji
|
||||
emoji_generator: !!python/name:materialx.emoji.to_svg
|
||||
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
||||
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
||||
- toc:
|
||||
permalink: "#"
|
||||
|
||||
|
@@ -8,7 +8,7 @@ build-backend = "hatchling.build"
|
||||
name = "superqt"
|
||||
description = "Missing widgets and components for PyQt/PySide"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
requires-python = ">=3.9"
|
||||
license = { text = "BSD 3-Clause License" }
|
||||
authors = [{ email = "talley.lambert@gmail.com", name = "Talley Lambert" }]
|
||||
keywords = [
|
||||
@@ -28,11 +28,11 @@ classifiers = [
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Desktop Environment",
|
||||
"Topic :: Software Development :: User Interfaces",
|
||||
"Topic :: Software Development :: Widget Sets",
|
||||
@@ -41,13 +41,21 @@ dynamic = ["version"]
|
||||
dependencies = [
|
||||
"pygments>=2.4.0",
|
||||
"qtpy>=1.1.0",
|
||||
"typing-extensions >=3.7.4.3,!=3.10.0.0",
|
||||
"typing-extensions >=3.7.4.3,!=3.10.0.0", # however, pint requires >4.5.0
|
||||
]
|
||||
|
||||
# extras
|
||||
# https://peps.python.org/pep-0621/#dependencies-optional-dependencies
|
||||
[project.optional-dependencies]
|
||||
test = ["pint", "pytest", "pytest-cov", "pytest-qt", "numpy", "cmap", "pyconify"]
|
||||
test = [
|
||||
"pint",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-qt==4.4.0",
|
||||
"numpy",
|
||||
"cmap",
|
||||
"pyconify",
|
||||
]
|
||||
dev = [
|
||||
"ipython",
|
||||
"ruff",
|
||||
@@ -57,8 +65,15 @@ dev = [
|
||||
"pydocstyle",
|
||||
"rich",
|
||||
"types-Pygments",
|
||||
"superqt[test,pyqt6]",
|
||||
]
|
||||
docs = [
|
||||
"mkdocs-macros-plugin ==1.3.7",
|
||||
"mkdocs-material ==9.5.49",
|
||||
"mkdocstrings ==0.27.0",
|
||||
"mkdocstrings-python ==1.13.0",
|
||||
"superqt[font-fa5, cmap, quantity]",
|
||||
]
|
||||
docs = ["mkdocs-macros-plugin", "mkdocs-material", "mkdocstrings[python]", "pint", "cmap"]
|
||||
quantity = ["pint"]
|
||||
cmap = ["cmap >=0.1.1"]
|
||||
pyside2 = ["pyside2"]
|
||||
@@ -66,7 +81,7 @@ pyside2 = ["pyside2"]
|
||||
# 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"]
|
||||
pyside6 = ["pyside6 !=6.5.0,!=6.5.1,!=6.6.2,<6.8"]
|
||||
pyqt5 = ["pyqt5"]
|
||||
pyqt6 = ["pyqt6<6.7"]
|
||||
font-fa5 = ["fonticon-fontawesome5"]
|
||||
@@ -100,21 +115,30 @@ python = ["3.11"]
|
||||
|
||||
[[tool.hatch.envs.test.matrix]]
|
||||
qt = ["pyside2", "pyqt5", "pyqt5.12"]
|
||||
python = ["3.8"]
|
||||
python = ["3.9"]
|
||||
|
||||
[tool.hatch.envs.test.overrides]
|
||||
matrix.qt.extra-dependencies = [
|
||||
{value = "pyside2", if = ["pyside2"]},
|
||||
{value = "pyside6", if = ["pyside6"]},
|
||||
{value = "pyqt5", if = ["pyqt5"]},
|
||||
{value = "pyqt6", if = ["pyqt6"]},
|
||||
{value = "pyqt5==5.12", if = ["pyqt5.12"]},
|
||||
{ value = "pyside2", if = [
|
||||
"pyside2",
|
||||
] },
|
||||
{ value = "pyside6", if = [
|
||||
"pyside6",
|
||||
] },
|
||||
{ value = "pyqt5", if = [
|
||||
"pyqt5",
|
||||
] },
|
||||
{ value = "pyqt6", if = [
|
||||
"pyqt6",
|
||||
] },
|
||||
{ value = "pyqt5==5.12", if = [
|
||||
"pyqt5.12",
|
||||
] },
|
||||
]
|
||||
|
||||
# https://github.com/charliermarsh/ruff
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
target-version = "py38"
|
||||
target-version = "py39"
|
||||
src = ["src", "tests"]
|
||||
|
||||
# https://docs.astral.sh/ruff/rules
|
||||
@@ -132,7 +156,7 @@ select = [
|
||||
"B", # flake8-bugbear
|
||||
"A001", # flake8-builtins
|
||||
"RUF", # ruff-specific rules
|
||||
"TCH", # flake8-type-checking
|
||||
"TC", # flake8-type-checking
|
||||
"TID", # flake8-tidy-imports
|
||||
]
|
||||
ignore = [
|
||||
@@ -159,6 +183,7 @@ filterwarnings = [
|
||||
"ignore:QPixmapCache.find:DeprecationWarning:",
|
||||
"ignore:SelectableGroups dict interface:DeprecationWarning",
|
||||
"ignore:The distutils package is deprecated:DeprecationWarning",
|
||||
"ignore:.*Skipping callback call set_result",
|
||||
]
|
||||
|
||||
# https://mypy.readthedocs.io/en/stable/config_file.html
|
||||
|
@@ -11,7 +11,6 @@ except PackageNotFoundError:
|
||||
from .collapsible import QCollapsible
|
||||
from .combobox import QColorComboBox, QEnumComboBox, QSearchableComboBox
|
||||
from .elidable import QElidingLabel, QElidingLineEdit
|
||||
from .iconify import QIconifyIcon
|
||||
from .selection import QSearchableListWidget, QSearchableTreeWidget
|
||||
from .sliders import (
|
||||
QDoubleRangeSlider,
|
||||
@@ -23,11 +22,15 @@ from .sliders import (
|
||||
QRangeSlider,
|
||||
)
|
||||
from .spinbox import QLargeIntSpinBox
|
||||
from .utils import QMessageHandler, ensure_main_thread, ensure_object_thread
|
||||
from .switch import QToggleSwitch
|
||||
from .utils import (
|
||||
QFlowLayout,
|
||||
QMessageHandler,
|
||||
ensure_main_thread,
|
||||
ensure_object_thread,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ensure_main_thread",
|
||||
"ensure_object_thread",
|
||||
"QCollapsible",
|
||||
"QColorComboBox",
|
||||
"QColormapComboBox",
|
||||
@@ -36,8 +39,9 @@ __all__ = [
|
||||
"QElidingLabel",
|
||||
"QElidingLineEdit",
|
||||
"QEnumComboBox",
|
||||
"QLabeledDoubleRangeSlider",
|
||||
"QFlowLayout",
|
||||
"QIconifyIcon",
|
||||
"QLabeledDoubleRangeSlider",
|
||||
"QLabeledDoubleSlider",
|
||||
"QLabeledRangeSlider",
|
||||
"QLabeledSlider",
|
||||
@@ -48,20 +52,28 @@ __all__ = [
|
||||
"QSearchableComboBox",
|
||||
"QSearchableListWidget",
|
||||
"QSearchableTreeWidget",
|
||||
"QToggleSwitch",
|
||||
"ensure_main_thread",
|
||||
"ensure_object_thread",
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .combobox import QColormapComboBox # noqa: TCH004
|
||||
from .spinbox._quantity import QQuantity # noqa: TCH004
|
||||
from .combobox import QColormapComboBox
|
||||
from .iconify import QIconifyIcon
|
||||
from .spinbox._quantity import QQuantity
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name == "QQuantity":
|
||||
from .spinbox._quantity import QQuantity
|
||||
|
||||
return QQuantity
|
||||
if name == "QColormapComboBox":
|
||||
from .cmap import QColormapComboBox
|
||||
|
||||
return QColormapComboBox
|
||||
if name == "QIconifyIcon":
|
||||
from .iconify import QIconifyIcon
|
||||
|
||||
return QIconifyIcon
|
||||
if name == "QQuantity":
|
||||
from .spinbox._quantity import QQuantity
|
||||
|
||||
return QQuantity
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
@@ -15,9 +15,9 @@ from ._cmap_line_edit import QColormapLineEdit
|
||||
from ._cmap_utils import draw_colormap
|
||||
|
||||
__all__ = [
|
||||
"QColormapItemDelegate",
|
||||
"draw_colormap",
|
||||
"QColormapLineEdit",
|
||||
"CmapCatalogComboBox",
|
||||
"QColormapComboBox",
|
||||
"QColormapItemDelegate",
|
||||
"QColormapLineEdit",
|
||||
"draw_colormap",
|
||||
]
|
||||
|
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Container
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from cmap import Colormap
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
@@ -11,6 +11,8 @@ from ._cmap_line_edit import QColormapLineEdit
|
||||
from ._cmap_utils import try_cast_colormap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Container
|
||||
|
||||
from cmap._catalog import Category, Interpolation
|
||||
from qtpy.QtGui import QKeyEvent
|
||||
|
||||
|
@@ -1,13 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from cmap import Colormap
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtCore import QSortFilterProxyModel, QStringListModel, Qt, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QCompleter,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QSizePolicy,
|
||||
@@ -23,7 +24,10 @@ from ._cmap_line_edit import QColormapLineEdit
|
||||
from ._cmap_utils import try_cast_colormap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from cmap._colormap import ColorStopsLike
|
||||
from qtpy.QtGui import QKeyEvent
|
||||
|
||||
|
||||
CMAP_ROLE = Qt.ItemDataRole.UserRole + 1
|
||||
@@ -43,6 +47,9 @@ class QColormapComboBox(QComboBox):
|
||||
add_colormap_text: str, optional
|
||||
The text to display for the "Add Colormap..." item.
|
||||
Default is "Add Colormap...".
|
||||
filterable: bool, optional
|
||||
Whether the user can filter colormaps by typing in the line edit.
|
||||
Default is True. Can also be set with `setFilterable`.
|
||||
"""
|
||||
|
||||
currentColormapChanged = Signal(Colormap)
|
||||
@@ -53,18 +60,20 @@ class QColormapComboBox(QComboBox):
|
||||
*,
|
||||
allow_user_colormaps: bool = False,
|
||||
add_colormap_text: str = "Add Colormap...",
|
||||
filterable: bool = True,
|
||||
) -> 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._filterable: bool = False
|
||||
|
||||
self.setLineEdit(_PopupColormapLineEdit(self))
|
||||
self.lineEdit().setReadOnly(True)
|
||||
line_edit = _PopupColormapLineEdit(self, allow_invalid=False)
|
||||
self.setLineEdit(line_edit)
|
||||
self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
|
||||
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
|
||||
@@ -73,6 +82,33 @@ class QColormapComboBox(QComboBox):
|
||||
|
||||
self.setUserAdditionsAllowed(allow_user_colormaps)
|
||||
|
||||
# Create a proxy model to handle filtering
|
||||
self._proxy_model = QSortFilterProxyModel(self)
|
||||
# use string list model as source model
|
||||
self._proxy_model.setSourceModel(QStringListModel(self))
|
||||
self._proxy_model.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||
|
||||
# Setup completer
|
||||
self._completer = QCompleter(self)
|
||||
self._completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||
self._completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
|
||||
self._completer.setFilterMode(Qt.MatchFlag.MatchContains)
|
||||
self._completer.setModel(self._proxy_model)
|
||||
|
||||
# set the delegate for both the popup and the combobox
|
||||
if popup := self._completer.popup():
|
||||
popup.setItemDelegate(self.itemDelegate())
|
||||
|
||||
# Update completer model when items change
|
||||
if model := self.model():
|
||||
model.rowsInserted.connect(self._update_completer_model)
|
||||
model.rowsRemoved.connect(self._update_completer_model)
|
||||
|
||||
self.setFilterable(filterable)
|
||||
|
||||
self.currentIndexChanged.connect(self._on_index_changed)
|
||||
line_edit.editingFinished.connect(self._on_editing_finished)
|
||||
|
||||
def userAdditionsAllowed(self) -> bool:
|
||||
"""Returns whether the user can add custom colors."""
|
||||
return self._allow_user_colors
|
||||
@@ -94,9 +130,26 @@ class QColormapComboBox(QComboBox):
|
||||
elif not self._allow_user_colors:
|
||||
self.removeItem(idx)
|
||||
|
||||
def setFilterable(self, filterable: bool) -> None:
|
||||
"""Set whether the user can enter/filter colormaps by typing in the line edit.
|
||||
|
||||
If enabled, the user can select the text in the line edit and type to
|
||||
filter the list of colormaps. The completer will show a list of matching
|
||||
colormaps as the user types. If disabled, the user can only select from
|
||||
the combo box dropdown.
|
||||
"""
|
||||
self._filterable = bool(filterable)
|
||||
self.setCompleter(self._completer if self._filterable else None)
|
||||
self.lineEdit().setReadOnly(not self._filterable)
|
||||
|
||||
def isFilterable(self) -> bool:
|
||||
"""Returns whether the user can filter the list of colormaps."""
|
||||
return self._filterable
|
||||
|
||||
def clear(self) -> None:
|
||||
super().clear()
|
||||
self.setUserAdditionsAllowed(self._allow_user_colors)
|
||||
self._update_completer_model()
|
||||
|
||||
def itemColormap(self, index: int) -> Colormap | None:
|
||||
"""Returns the color of the item at the given index."""
|
||||
@@ -122,14 +175,23 @@ class QColormapComboBox(QComboBox):
|
||||
# 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)
|
||||
self._block_completer_update = True
|
||||
try:
|
||||
with signals_blocked(self):
|
||||
self.removeItem(idx)
|
||||
self.addItem(self._add_color_text)
|
||||
finally:
|
||||
self._block_completer_update = False
|
||||
|
||||
def addColormaps(self, colors: Sequence[Any]) -> None:
|
||||
"""Adds colors to the QComboBox."""
|
||||
for color in colors:
|
||||
self.addColormap(color)
|
||||
self._block_completer_update = True
|
||||
try:
|
||||
for color in colors:
|
||||
self.addColormap(color)
|
||||
finally:
|
||||
self._block_completer_update = False
|
||||
self._update_completer_model()
|
||||
|
||||
def currentColormap(self) -> Colormap | None:
|
||||
"""Returns the currently selected Colormap or None if not yet selected."""
|
||||
@@ -171,6 +233,37 @@ class QColormapComboBox(QComboBox):
|
||||
self.lineEdit().setColormap(colormap)
|
||||
self._last_cmap = colormap
|
||||
|
||||
def _update_completer_model(self) -> None:
|
||||
"""Update the completer's model with current items."""
|
||||
if getattr(self, "_block_completer_update", False):
|
||||
return
|
||||
|
||||
# Ensure we are updating the source model of the proxy
|
||||
if isinstance(src_model := self._proxy_model.sourceModel(), QStringListModel):
|
||||
words = [
|
||||
txt
|
||||
for i in range(self.count())
|
||||
if (txt := self.itemText(i)) != self._add_color_text
|
||||
]
|
||||
src_model.setStringList(words)
|
||||
self._proxy_model.invalidate()
|
||||
|
||||
def _on_editing_finished(self) -> None:
|
||||
text = self.lineEdit().text()
|
||||
if (cmap := try_cast_colormap(text)) is not None:
|
||||
self.currentColormapChanged.emit(cmap)
|
||||
|
||||
# if the cmap is not in the list, add it
|
||||
if self.findData(cmap, CMAP_ROLE) < 0:
|
||||
self.addColormap(cmap)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
CATEGORIES = ("sequential", "diverging", "cyclic", "qualitative", "miscellaneous")
|
||||
|
||||
@@ -218,7 +311,9 @@ class _PopupColormapLineEdit(QColormapLineEdit):
|
||||
|
||||
Without this, only the down arrow will show the popup. And if mousePressEvent
|
||||
is used instead, the popup will show and then immediately hide.
|
||||
Also ensure that the popup is not shown when the user selects text.
|
||||
"""
|
||||
parent = self.parent()
|
||||
if parent and hasattr(parent, "showPopup"):
|
||||
parent.showPopup()
|
||||
if not self.hasSelectedText():
|
||||
parent = self.parent()
|
||||
if parent and hasattr(parent, "showPopup"):
|
||||
parent.showPopup()
|
||||
|
@@ -43,6 +43,13 @@ class QColormapLineEdit(QLineEdit):
|
||||
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.
|
||||
allow_invalid : bool, optional
|
||||
If True, the user can enter any text, even if it does not represent a valid
|
||||
colormap (and `fallback_cmap` will be shown if it's invalid). If False, the text
|
||||
will be validated when editing is finished or focus is lost, and if the text is
|
||||
not a valid colormap, it will be reverted to the first available valid option
|
||||
from the completer, or, if that's not available, the last valid colormap.
|
||||
Default is True. This is only settable at initialization.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -53,6 +60,7 @@ class QColormapLineEdit(QLineEdit):
|
||||
fallback_cmap: Colormap | str | None = "gray",
|
||||
missing_icon: QIcon | QStyle.StandardPixmap = MISSING,
|
||||
checkerboard_size: int = 4,
|
||||
allow_invalid: bool = True,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setFractionalColormapWidth(fractional_colormap_width)
|
||||
@@ -69,6 +77,45 @@ class QColormapLineEdit(QLineEdit):
|
||||
self._cmap: Colormap | None = None # current colormap
|
||||
self.textChanged.connect(self.setColormap)
|
||||
|
||||
self._lastValidColormap: Colormap | None = None
|
||||
if not allow_invalid:
|
||||
self.editingFinished.connect(self._validate)
|
||||
|
||||
def _validate(self) -> None:
|
||||
"""Called when editing is finished or focus is lost.
|
||||
|
||||
If the current text does not represent a valid colormap, revert to the first
|
||||
available valid option from the completer, or, if that's not available, revert
|
||||
to the last valid colormap.
|
||||
"""
|
||||
if self._cmap is None:
|
||||
candidate = self._fist_completer_option()
|
||||
if candidate is not None:
|
||||
self.setColormap(candidate)
|
||||
self.setText(candidate.name.rsplit(":", 1)[-1])
|
||||
elif self._lastValidColormap is not None:
|
||||
self.setColormap(self._lastValidColormap)
|
||||
self.setText(self._lastValidColormap.name.rsplit(":", 1)[-1])
|
||||
# Optionally, if neither is available, you might decide to clear the text.
|
||||
else:
|
||||
# Update the last valid value.
|
||||
self._lastValidColormap = self._cmap
|
||||
|
||||
def _fist_completer_option(self) -> Colormap | None:
|
||||
"""Return the first valid Colormap from the completer's current filtered list.
|
||||
|
||||
or None if no valid option is available.
|
||||
"""
|
||||
if (
|
||||
(completer := self.completer()) is None
|
||||
or (model := completer.model()) is None
|
||||
or model.rowCount() == 0
|
||||
):
|
||||
return None
|
||||
|
||||
first_item = model.index(0, 0).data(Qt.ItemDataRole.DisplayRole)
|
||||
return try_cast_colormap(first_item)
|
||||
|
||||
def setFractionalColormapWidth(self, fraction: float) -> None:
|
||||
self._colormap_fraction: float = float(fraction)
|
||||
align = Qt.AlignmentFlag.AlignVCenter
|
||||
|
@@ -121,6 +121,10 @@ def draw_colormap(
|
||||
painter.setBrush(gradient)
|
||||
painter.drawRect(rect)
|
||||
|
||||
# If we created a new Painter, free its resources
|
||||
if isinstance(painter_or_device, QPaintDevice):
|
||||
painter.end()
|
||||
|
||||
|
||||
def _draw_checkerboard(
|
||||
painter: QPainter, rect: QRect | QRectF, checker_size: int
|
||||
|
@@ -13,7 +13,7 @@ __all__ = (
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superqt.cmap import QColormapComboBox # noqa: TCH004
|
||||
from superqt.cmap import QColormapComboBox
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any: # pragma: no cover
|
||||
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import warnings
|
||||
from contextlib import suppress
|
||||
from enum import IntEnum, auto
|
||||
from typing import Any, Literal, Sequence, cast
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
from qtpy.QtCore import QModelIndex, QPersistentModelIndex, QRect, QSize, Qt, Signal
|
||||
from qtpy.QtGui import QColor, QPainter
|
||||
@@ -19,6 +19,9 @@ from qtpy.QtWidgets import (
|
||||
|
||||
from superqt.utils import signals_blocked
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
_NAME_MAP = {QColor(x).name(): x for x in QColor.colorNames()}
|
||||
|
||||
COLOR_ROLE = Qt.ItemDataRole.BackgroundRole
|
||||
|
@@ -3,7 +3,7 @@ from enum import Enum, EnumMeta, Flag
|
||||
from functools import reduce
|
||||
from itertools import combinations
|
||||
from operator import or_
|
||||
from typing import Optional, Tuple, TypeVar
|
||||
from typing import Optional, TypeVar
|
||||
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import QComboBox
|
||||
@@ -47,7 +47,7 @@ def _get_name(enum_value: Enum):
|
||||
return name
|
||||
|
||||
|
||||
def _get_name_with_value(enum_value: Enum) -> Tuple[str, Enum]:
|
||||
def _get_name_with_value(enum_value: Enum) -> tuple[str, Enum]:
|
||||
return _get_name(enum_value), enum_value
|
||||
|
||||
|
||||
|
@@ -1,5 +1,3 @@
|
||||
from typing import List
|
||||
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtGui import QFont, QFontMetrics, QTextLayout
|
||||
|
||||
@@ -36,7 +34,7 @@ class _GenericEliding:
|
||||
self._ellipses_width = width
|
||||
|
||||
@staticmethod
|
||||
def wrapText(text, width, font=None) -> List[str]:
|
||||
def wrapText(text, width, font=None) -> list[str]:
|
||||
"""Returns `text`, split as it would be wrapped for `width`, given `font`.
|
||||
|
||||
Static method.
|
||||
@@ -72,7 +70,7 @@ class _GenericEliding:
|
||||
text = self._wrappedText()
|
||||
last_line = fm.elidedText("".join(text[nlines:]), self._elide_mode, width)
|
||||
# join them
|
||||
return "".join(text[:nlines] + [last_line])
|
||||
return "".join([*text[:nlines], last_line])
|
||||
|
||||
def _wrappedText(self) -> List[str]:
|
||||
def _wrappedText(self) -> list[str]:
|
||||
return _GenericEliding.wrapText(self._text, self.width(), self.font())
|
||||
|
@@ -73,3 +73,10 @@ class QElidingLabel(_GenericEliding, QLabel):
|
||||
flags = int(self.alignment() | Qt.TextFlag.TextWordWrap)
|
||||
r = fm.boundingRect(QRect(QPoint(0, 0), self.size()), flags, self._text)
|
||||
return QSize(self.width(), r.height())
|
||||
|
||||
def minimumSizeHint(self) -> QSize:
|
||||
# The smallest that self._elidedText can be is just the ellipsis.
|
||||
fm = QFontMetrics(self.font())
|
||||
flags = int(self.alignment() | Qt.TextFlag.TextWordWrap)
|
||||
r = fm.boundingRect(QRect(QPoint(0, 0), self.size()), flags, "...")
|
||||
return QSize(r.width(), r.height())
|
||||
|
@@ -1,16 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"addFont",
|
||||
"Animation",
|
||||
"ENTRY_POINT",
|
||||
"font",
|
||||
"icon",
|
||||
"Animation",
|
||||
"IconFont",
|
||||
"IconFontMeta",
|
||||
"IconOpts",
|
||||
"pulse",
|
||||
"QIconifyIcon",
|
||||
"addFont",
|
||||
"font",
|
||||
"icon",
|
||||
"pulse",
|
||||
"setTextIcon",
|
||||
"spin",
|
||||
]
|
||||
|
@@ -1,4 +1,5 @@
|
||||
from typing import Mapping, Type, Union
|
||||
from collections.abc import Mapping
|
||||
from typing import Union
|
||||
|
||||
FONTFILE_ATTR = "__font_file__"
|
||||
|
||||
@@ -69,7 +70,7 @@ class IconFont(metaclass=IconFontMeta):
|
||||
__font_file__ = "..."
|
||||
|
||||
|
||||
def namespace2font(namespace: Union[Mapping, Type], name: str) -> Type[IconFont]:
|
||||
def namespace2font(namespace: Union[Mapping, type], name: str) -> type[IconFont]:
|
||||
"""Convenience to convert a namespace (class, module, dict) into an IconFont."""
|
||||
if isinstance(namespace, type):
|
||||
if not isinstance(getattr(namespace, FONTFILE_ATTR), str):
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import contextlib
|
||||
from typing import ClassVar, Dict, List, Set, Tuple
|
||||
from typing import ClassVar
|
||||
|
||||
from ._iconfont import IconFontMeta, namespace2font
|
||||
|
||||
@@ -11,9 +11,9 @@ except ImportError:
|
||||
|
||||
class FontIconManager:
|
||||
ENTRY_POINT: ClassVar[str] = "superqt.fonticon"
|
||||
_PLUGINS: ClassVar[Dict[str, EntryPoint]] = {}
|
||||
_LOADED: ClassVar[Dict[str, IconFontMeta]] = {}
|
||||
_BLOCKED: ClassVar[Set[EntryPoint]] = set()
|
||||
_PLUGINS: ClassVar[dict[str, EntryPoint]] = {}
|
||||
_LOADED: ClassVar[dict[str, IconFontMeta]] = {}
|
||||
_BLOCKED: ClassVar[set[EntryPoint]] = set()
|
||||
|
||||
def _discover_fonts(self) -> None:
|
||||
self._PLUGINS.clear()
|
||||
@@ -86,15 +86,15 @@ _manager = FontIconManager()
|
||||
get_font_class = _manager._get_font_class
|
||||
|
||||
|
||||
def discover() -> Tuple[str]:
|
||||
def discover() -> tuple[str]:
|
||||
_manager._discover_fonts()
|
||||
|
||||
|
||||
def available() -> Tuple[str]:
|
||||
def available() -> tuple[str]:
|
||||
return tuple(_manager._PLUGINS)
|
||||
|
||||
|
||||
def loaded(load_all=False) -> Dict[str, List[str]]:
|
||||
def loaded(load_all=False) -> dict[str, list[str]]:
|
||||
if load_all:
|
||||
discover()
|
||||
for x in available():
|
||||
|
@@ -2,9 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from collections import abc, defaultdict
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, ClassVar, DefaultDict, Sequence, Tuple, Union, cast
|
||||
from typing import TYPE_CHECKING, ClassVar, Union, cast
|
||||
|
||||
from qtpy import QT_VERSION
|
||||
from qtpy.QtCore import QObject, QPoint, QRect, QSize, Qt
|
||||
@@ -47,8 +48,8 @@ ValidColor = Union[
|
||||
int,
|
||||
str,
|
||||
Qt.GlobalColor,
|
||||
Tuple[int, int, int, int],
|
||||
Tuple[int, int, int],
|
||||
tuple[int, int, int, int],
|
||||
tuple[int, int, int],
|
||||
None,
|
||||
]
|
||||
|
||||
@@ -131,7 +132,7 @@ class IconOpts:
|
||||
def dict(self) -> IconOptionDict:
|
||||
# not using asdict due to pickle errors on animation
|
||||
d = {k: v for k, v in vars(self).items() if v is not _Unset}
|
||||
return cast(IconOptionDict, d)
|
||||
return cast("IconOptionDict", d)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -150,7 +151,7 @@ class _IconOptions:
|
||||
|
||||
def dict(self) -> IconOptionDict:
|
||||
# not using asdict due to pickle errors on animation
|
||||
return cast(IconOptionDict, vars(self))
|
||||
return cast("IconOptionDict", vars(self))
|
||||
|
||||
|
||||
class _QFontIconEngine(QIconEngine):
|
||||
@@ -159,14 +160,14 @@ class _QFontIconEngine(QIconEngine):
|
||||
def __init__(self, options: _IconOptions):
|
||||
super().__init__()
|
||||
self._opts: defaultdict[QIcon.State, dict[QIcon.Mode, _IconOptions | None]] = (
|
||||
DefaultDict(dict)
|
||||
defaultdict(dict)
|
||||
)
|
||||
self._opts[QIcon.State.Off][QIcon.Mode.Normal] = options
|
||||
self.update_hash()
|
||||
|
||||
@property
|
||||
def _default_opts(self) -> _IconOptions:
|
||||
return cast(_IconOptions, self._opts[QIcon.State.Off][QIcon.Mode.Normal])
|
||||
return cast("_IconOptions", self._opts[QIcon.State.Off][QIcon.Mode.Normal])
|
||||
|
||||
def _add_opts(self, state: QIcon.State, mode: QIcon.Mode, opts: IconOpts) -> None:
|
||||
self._opts[state][mode] = self._default_opts._update(opts)
|
||||
@@ -357,7 +358,7 @@ class QFontIconStore(QObject):
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent=parent)
|
||||
if tuple(cast(str, QT_VERSION).split(".")) < ("6", "0"):
|
||||
if tuple(cast("str", QT_VERSION).split(".")) < ("6", "0"):
|
||||
# QT6 drops this
|
||||
QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps)
|
||||
|
||||
@@ -479,7 +480,7 @@ class QFontIconStore(QObject):
|
||||
# in Qt6, everything becomes a static member
|
||||
QFd: QFontDatabase | type[QFontDatabase] = (
|
||||
QFontDatabase()
|
||||
if tuple(cast(str, QT_VERSION).split(".")) < ("6", "0")
|
||||
if tuple(cast("str", QT_VERSION).split(".")) < ("6", "0")
|
||||
else QFontDatabase
|
||||
)
|
||||
|
||||
|
@@ -1,9 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QIcon, QPainter, QPixmap
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
try:
|
||||
from pyconify import svg_path
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
raise ModuleNotFoundError(
|
||||
"pyconify is required to use QIconifyIcon. "
|
||||
"Please install it with `pip install pyconify` or use the "
|
||||
"`pip install superqt[iconify]` extra."
|
||||
) from None
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Literal
|
||||
@@ -11,10 +22,7 @@ if TYPE_CHECKING:
|
||||
Flip = Literal["horizontal", "vertical", "horizontal,vertical"]
|
||||
Rotation = Literal["90", "180", "270", 90, 180, 270, "-90", 1, 2, 3]
|
||||
|
||||
try:
|
||||
from pyconify import svg_path
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
svg_path = None
|
||||
__all__ = ["QIconifyIcon"]
|
||||
|
||||
|
||||
class QIconifyIcon(QIcon):
|
||||
@@ -72,14 +80,9 @@ class QIconifyIcon(QIcon):
|
||||
rotate: Rotation | None = None,
|
||||
dir: str | None = None,
|
||||
):
|
||||
if svg_path is None: # pragma: no cover
|
||||
raise ModuleNotFoundError(
|
||||
"pyconify is required to use QIconifyIcon. "
|
||||
"Please install it with `pip install pyconify` or use the "
|
||||
"`pip install superqt[iconify]` extra."
|
||||
)
|
||||
super().__init__()
|
||||
self.addKey(*key, color=color, flip=flip, rotate=rotate, dir=dir)
|
||||
if key:
|
||||
self.addKey(*key, color=color, flip=flip, rotate=rotate, dir=dir)
|
||||
|
||||
def addKey(
|
||||
self,
|
||||
@@ -91,7 +94,7 @@ class QIconifyIcon(QIcon):
|
||||
size: QSize | None = None,
|
||||
mode: QIcon.Mode = QIcon.Mode.Normal,
|
||||
state: QIcon.State = QIcon.State.Off,
|
||||
) -> None:
|
||||
) -> QIconifyIcon:
|
||||
"""Add an icon to this QIcon.
|
||||
|
||||
This is a variant of `QIcon.addFile` that uses an iconify icon keys and
|
||||
@@ -121,6 +124,33 @@ class QIconifyIcon(QIcon):
|
||||
Mode specified for the icon, passed to `QIcon.addFile`.
|
||||
state : QIcon.State, optional
|
||||
State specified for the icon, passed to `QIcon.addFile`.
|
||||
|
||||
Returns
|
||||
-------
|
||||
QIconifyIcon
|
||||
This QIconifyIcon instance, for chaining.
|
||||
"""
|
||||
path = svg_path(*key, color=color, flip=flip, rotate=rotate, dir=dir)
|
||||
self.addFile(str(path), size or QSize(), mode, state)
|
||||
try:
|
||||
path = svg_path(*key, color=color, flip=flip, rotate=rotate, dir=dir)
|
||||
except OSError as e:
|
||||
warnings.warn(
|
||||
f"Error fetching icon: {e}.\nIcon {key} not cached. Using fallback.",
|
||||
stacklevel=2,
|
||||
)
|
||||
self._draw_text_fallback(key)
|
||||
else:
|
||||
self.addFile(str(path), size or QSize(), mode, state)
|
||||
|
||||
return self
|
||||
|
||||
def _draw_text_fallback(self, key: tuple[str, ...]) -> None:
|
||||
if style := QApplication.style():
|
||||
pixmap = style.standardPixmap(style.StandardPixmap.SP_MessageBoxQuestion)
|
||||
else:
|
||||
pixmap = QPixmap(18, 18)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "?")
|
||||
painter.end()
|
||||
|
||||
self.addPixmap(pixmap)
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from typing import Any, Iterable, Mapping
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from qtpy.QtCore import QRegularExpression
|
||||
from qtpy.QtWidgets import QLineEdit, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
@@ -16,7 +17,7 @@ class QSearchableTreeWidget(QWidget):
|
||||
into the `filter` line edit. An item is only shown if its, any of its ancestors',
|
||||
or any of its descendants' keys or values match this pattern.
|
||||
The regular expression follows the conventions described by the Qt docs:
|
||||
https://doc.qt.io/qt-5/qregularexpression.html#details
|
||||
https://doc.qt.io/qt-6/qregularexpression.html#details
|
||||
|
||||
Attributes
|
||||
----------
|
||||
|
@@ -8,6 +8,7 @@ from ._range_style import MONTEREY_SLIDER_STYLES_FIX
|
||||
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
||||
|
||||
__all__ = [
|
||||
"MONTEREY_SLIDER_STYLES_FIX",
|
||||
"QDoubleRangeSlider",
|
||||
"QDoubleSlider",
|
||||
"QLabeledDoubleRangeSlider",
|
||||
@@ -15,5 +16,4 @@ __all__ = [
|
||||
"QLabeledRangeSlider",
|
||||
"QLabeledSlider",
|
||||
"QRangeSlider",
|
||||
"MONTEREY_SLIDER_STYLES_FIX",
|
||||
]
|
||||
|
@@ -1,4 +1,5 @@
|
||||
from typing import List, Optional, Sequence, Tuple, TypeVar, Union
|
||||
from collections.abc import Sequence
|
||||
from typing import Optional, TypeVar, Union
|
||||
|
||||
from qtpy import QtGui
|
||||
from qtpy.QtCore import Property, QEvent, QPoint, QPointF, QRect, QRectF, Qt, Signal
|
||||
@@ -28,25 +29,27 @@ class _GenericRangeSlider(_GenericSlider):
|
||||
"""
|
||||
|
||||
# Emitted when the slider value has changed, with the new slider values
|
||||
_valuesChanged = Signal(tuple)
|
||||
valuesChanged = Signal(tuple)
|
||||
# this is just a hack to allow napari v0.4.19 tests to pass)
|
||||
# since it used the presence of this private signal as a duck-typing check.
|
||||
_valuesChanged = valuesChanged
|
||||
|
||||
# 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.
|
||||
_slidersMoved = Signal(tuple)
|
||||
slidersMoved = Signal(tuple)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._style = RangeSliderStyle()
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
self.valueChanged = self._valuesChanged
|
||||
self.sliderMoved = self._slidersMoved
|
||||
|
||||
# list of values
|
||||
self._value: List[_T] = [20, 80]
|
||||
self._value: list[_T] = [20, 80]
|
||||
|
||||
# list of current positions of each handle. same length as _value
|
||||
# If tracking is enabled (the default) this will be identical to _value
|
||||
self._position: List[_T] = [20, 80]
|
||||
self._position: list[_T] = [20, 80]
|
||||
|
||||
# which handle is being pressed/hovered
|
||||
self._pressedIndex = 0
|
||||
@@ -63,6 +66,10 @@ class _GenericRangeSlider(_GenericSlider):
|
||||
|
||||
self.setStyleSheet("")
|
||||
|
||||
def _rename_signals(self) -> None:
|
||||
self.valueChanged = self.valuesChanged
|
||||
self.sliderMoved = self.slidersMoved
|
||||
|
||||
# ############### New Public API #######################
|
||||
|
||||
def barIsRigid(self) -> bool:
|
||||
@@ -113,7 +120,7 @@ class _GenericRangeSlider(_GenericSlider):
|
||||
|
||||
# ############### QtOverrides #######################
|
||||
|
||||
def value(self) -> Tuple[_T, ...]:
|
||||
def value(self) -> tuple[_T, ...]:
|
||||
"""Get current value of the widget as a tuple of integers."""
|
||||
return tuple(self._value)
|
||||
|
||||
@@ -332,7 +339,7 @@ class _GenericRangeSlider(_GenericSlider):
|
||||
# NOTE: this is very much tied to mousepress... not a generic "get control"
|
||||
def _getControlAtPos(
|
||||
self, pos: QPoint, opt: Optional[QStyleOptionSlider] = None
|
||||
) -> Tuple[QStyle.SubControl, int]:
|
||||
) -> tuple[QStyle.SubControl, int]:
|
||||
"""Update self._pressedControl based on ev.pos()."""
|
||||
opt = opt or self._styleOption
|
||||
|
||||
|
@@ -22,7 +22,7 @@ QRangeSlider.
|
||||
|
||||
import os
|
||||
import platform
|
||||
from typing import TypeVar
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from qtpy import QT_VERSION, QtGui
|
||||
from qtpy.QtCore import QEvent, QPoint, QPointF, QRect, Qt, Signal
|
||||
@@ -60,13 +60,13 @@ USE_MAC_SLIDER_PATCH = (
|
||||
|
||||
|
||||
class _GenericSlider(QSlider):
|
||||
_fvalueChanged = Signal(int)
|
||||
_fsliderMoved = Signal(int)
|
||||
_frangeChanged = Signal(int, int)
|
||||
fvalueChanged = Signal(float)
|
||||
fsliderMoved = Signal(float)
|
||||
frangeChanged = Signal(float, float)
|
||||
|
||||
MAX_DISPLAY = 5000
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
self._minimum = 0.0
|
||||
self._maximum = 99.0
|
||||
self._pageStep = 10.0
|
||||
@@ -90,15 +90,18 @@ class _GenericSlider(QSlider):
|
||||
self._control_fraction = 0.04
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
self.valueChanged = self._fvalueChanged
|
||||
self.sliderMoved = self._fsliderMoved
|
||||
self.rangeChanged = self._frangeChanged
|
||||
self._rename_signals()
|
||||
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_Hover)
|
||||
self.setStyleSheet("")
|
||||
if USE_MAC_SLIDER_PATCH:
|
||||
self.applyMacStylePatch()
|
||||
|
||||
def _rename_signals(self) -> None:
|
||||
self.valueChanged = self.fvalueChanged
|
||||
self.sliderMoved = self.fsliderMoved
|
||||
self.rangeChanged = self.frangeChanged
|
||||
|
||||
def applyMacStylePatch(self) -> None:
|
||||
"""Apply a QSS patch to fix sliders on macos>=12 with QT < 6.
|
||||
|
||||
|
@@ -1,20 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from enum import IntEnum, IntFlag, auto
|
||||
from functools import partial
|
||||
from typing import Any, Iterable, overload
|
||||
from typing import TYPE_CHECKING, Any, overload
|
||||
|
||||
from qtpy import QtGui
|
||||
from qtpy.QtCore import Property, QPoint, QSize, Qt, Signal
|
||||
from qtpy.QtGui import QFontMetrics, QValidator
|
||||
from qtpy.QtGui import QDoubleValidator, QFontMetrics, QValidator
|
||||
from qtpy.QtWidgets import (
|
||||
QAbstractSlider,
|
||||
QBoxLayout,
|
||||
QDoubleSpinBox,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QSlider,
|
||||
QSpinBox,
|
||||
QStyle,
|
||||
QStyleOptionSpinBox,
|
||||
QVBoxLayout,
|
||||
@@ -25,6 +23,9 @@ from superqt.utils import signals_blocked
|
||||
|
||||
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
class LabelPosition(IntEnum):
|
||||
NoLabel = 0
|
||||
@@ -80,7 +81,7 @@ class _SliderProxy:
|
||||
def setPageStep(self, step: int) -> None:
|
||||
self._slider.setPageStep(step)
|
||||
|
||||
def setRange(self, min: int, max: int) -> None:
|
||||
def setRange(self, min: float, max: float) -> None:
|
||||
self._slider.setRange(min, max)
|
||||
|
||||
def tickInterval(self) -> int:
|
||||
@@ -159,9 +160,6 @@ def _handle_overloaded_slider_sig(
|
||||
|
||||
class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||
editingFinished = Signal()
|
||||
_ivalueChanged = Signal(int)
|
||||
_isliderMoved = Signal(int)
|
||||
_irangeChanged = Signal(int, int)
|
||||
|
||||
_slider_class = QSlider
|
||||
_slider: QSlider
|
||||
@@ -185,6 +183,7 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||
self._slider = self._slider_class(parent=self)
|
||||
self._label = SliderLabel(self._slider, connect=self._setValue, parent=self)
|
||||
self._edge_label_mode: EdgeLabelMode = EdgeLabelMode.LabelIsValue
|
||||
self._edge_label_position: LabelPosition = LabelPosition.LabelsRight
|
||||
|
||||
self._rename_signals()
|
||||
self._slider.actionTriggered.connect(self.actionTriggered.emit)
|
||||
@@ -205,18 +204,29 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||
marg = (0, 0, 0, 0)
|
||||
if orientation == Qt.Orientation.Vertical:
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(self._slider, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
layout.addWidget(self._label, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
if not self._edge_label_position:
|
||||
layout.addWidget(self._slider, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
elif self._edge_label_position == LabelPosition.LabelsBelow:
|
||||
layout.addWidget(self._slider, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
layout.addWidget(self._label, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
else:
|
||||
layout.addWidget(self._label, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
layout.addWidget(self._slider, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
self._label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.setSpacing(1)
|
||||
else:
|
||||
if self._edge_label_mode == EdgeLabelMode.NoLabel:
|
||||
marg = (0, 0, 5, 0)
|
||||
|
||||
layout = QHBoxLayout() # type: ignore
|
||||
layout.addWidget(self._slider)
|
||||
layout.addWidget(self._label)
|
||||
self._label.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
if not self._edge_label_position:
|
||||
layout.addWidget(self._slider)
|
||||
elif self._edge_label_position == LabelPosition.LabelsRight:
|
||||
layout.addWidget(self._slider)
|
||||
layout.addWidget(self._label)
|
||||
self._label.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
else:
|
||||
layout.addWidget(self._label)
|
||||
layout.addWidget(self._slider)
|
||||
self._label.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
marg = (0, 0, 5, 0)
|
||||
layout.setSpacing(6)
|
||||
|
||||
old_layout = self.layout()
|
||||
@@ -249,27 +259,46 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||
)
|
||||
|
||||
self._edge_label_mode = opt
|
||||
self._on_slider_range_changed(self.minimum(), self.maximum())
|
||||
if not self._edge_label_mode:
|
||||
self._label.hide()
|
||||
w = 5 if self.orientation() == Qt.Orientation.Horizontal else 0
|
||||
self.layout().setContentsMargins(0, 0, w, 0)
|
||||
if self._edge_label_position == LabelPosition.LabelsRight:
|
||||
self.layout().setContentsMargins(0, 0, w, 0)
|
||||
elif self._edge_label_position == LabelPosition.LabelsLeft:
|
||||
self.layout().setContentsMargins(0, 0, 0, w)
|
||||
if opt & EdgeLabelMode.LabelIsValue:
|
||||
if self.isVisible():
|
||||
self._label.show()
|
||||
self._label.setMode(opt)
|
||||
self._label.setValue(self._slider.value())
|
||||
self.layout().setContentsMargins(0, 0, 0, 0)
|
||||
self._on_slider_range_changed(self.minimum(), self.maximum())
|
||||
|
||||
def edgeLabelPosition(self) -> LabelPosition:
|
||||
"""Return where/whether a label is shown at the edge of the slider."""
|
||||
return self._edge_label_position
|
||||
|
||||
def setEdgeLabelPosition(self, opt: LabelPosition) -> None:
|
||||
"""Set where/whether a label is shown at the edge of the slider."""
|
||||
if opt is LabelPosition.LabelsOnHandle:
|
||||
raise ValueError("position cannot be 'LabelPosition.LabelsOnHandle'")
|
||||
|
||||
self._edge_label_position = opt
|
||||
self._label.setVisible(bool(opt))
|
||||
# TODO: make double clickable to edit
|
||||
self.setOrientation(self.orientation())
|
||||
|
||||
# putting this after labelMode methods for the sake of mypy
|
||||
EdgeLabelMode = EdgeLabelMode
|
||||
LabelPosition = LabelPosition
|
||||
|
||||
# --------------------- private api --------------------
|
||||
|
||||
def _on_slider_range_changed(self, min_: int, max_: int) -> None:
|
||||
slash = " / " if self._edge_label_mode & EdgeLabelMode.LabelIsValue else ""
|
||||
if self._edge_label_mode & EdgeLabelMode.LabelIsRange:
|
||||
self._label.setSuffix(f"{slash}{max_}")
|
||||
self._label.setSuffix(f" / {max_}")
|
||||
else:
|
||||
self._label.setSuffix("")
|
||||
self.rangeChanged.emit(min_, max_)
|
||||
|
||||
def _on_slider_value_changed(self, v: Any) -> None:
|
||||
@@ -280,18 +309,15 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||
"""Convert the value from float to int before setting the slider value."""
|
||||
self._slider.setValue(int(value))
|
||||
|
||||
def _rename_signals(self) -> None:
|
||||
self.valueChanged = self._ivalueChanged
|
||||
self.sliderMoved = self._isliderMoved
|
||||
self.rangeChanged = self._irangeChanged
|
||||
def _rename_signals(self) -> None: ...
|
||||
|
||||
|
||||
class QLabeledDoubleSlider(QLabeledSlider):
|
||||
_slider_class = QDoubleSlider
|
||||
_slider: QDoubleSlider
|
||||
_fvalueChanged = Signal(float)
|
||||
_fsliderMoved = Signal(float)
|
||||
_frangeChanged = Signal(float, float)
|
||||
fvalueChanged = Signal(float)
|
||||
fsliderMoved = Signal(float)
|
||||
frangeChanged = Signal(float, float)
|
||||
|
||||
@overload
|
||||
def __init__(self, parent: QWidget | None = ...) -> None: ...
|
||||
@@ -310,9 +336,9 @@ class QLabeledDoubleSlider(QLabeledSlider):
|
||||
self._slider.setValue(value)
|
||||
|
||||
def _rename_signals(self) -> None:
|
||||
self.valueChanged = self._fvalueChanged
|
||||
self.sliderMoved = self._fsliderMoved
|
||||
self.rangeChanged = self._frangeChanged
|
||||
self.valueChanged = self.fvalueChanged
|
||||
self.sliderMoved = self.fsliderMoved
|
||||
self.rangeChanged = self.frangeChanged
|
||||
|
||||
def decimals(self) -> int:
|
||||
return self._label.decimals()
|
||||
@@ -322,9 +348,7 @@ class QLabeledDoubleSlider(QLabeledSlider):
|
||||
|
||||
|
||||
class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
_valueChanged = Signal(tuple)
|
||||
_sliderPressed = Signal()
|
||||
_sliderReleased = Signal()
|
||||
valuesChanged = Signal(tuple)
|
||||
editingFinished = Signal()
|
||||
|
||||
_slider_class = QRangeSlider
|
||||
@@ -356,7 +380,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
self._slider.sliderPressed.connect(self.sliderPressed.emit)
|
||||
self._slider.sliderReleased.connect(self.sliderReleased.emit)
|
||||
self._slider.rangeChanged.connect(self.rangeChanged.emit)
|
||||
self.sliderMoved = self._slider._slidersMoved
|
||||
self.sliderMoved = self._slider.slidersMoved
|
||||
|
||||
self._min_label = SliderLabel(
|
||||
self._slider,
|
||||
@@ -489,9 +513,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
|
||||
# ------------- private methods ----------------
|
||||
def _rename_signals(self) -> None:
|
||||
self.valueChanged = self._valueChanged
|
||||
self.sliderReleased = self._sliderReleased
|
||||
self.sliderPressed = self._sliderPressed
|
||||
self.valueChanged = self.valuesChanged
|
||||
|
||||
def _reposition_labels(self) -> None:
|
||||
if (
|
||||
@@ -599,7 +621,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
|
||||
_slider_class = QDoubleRangeSlider
|
||||
_slider: QDoubleRangeSlider
|
||||
_frangeChanged = Signal(float, float)
|
||||
frangeChanged = Signal(float, float)
|
||||
|
||||
@overload
|
||||
def __init__(self, parent: QWidget | None = ...) -> None: ...
|
||||
@@ -615,7 +637,7 @@ class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
|
||||
|
||||
def _rename_signals(self) -> None:
|
||||
super()._rename_signals()
|
||||
self.rangeChanged = self._frangeChanged
|
||||
self.rangeChanged = self.frangeChanged
|
||||
|
||||
def decimals(self) -> int:
|
||||
return self._min_label.decimals()
|
||||
@@ -636,7 +658,7 @@ class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
|
||||
"""The color of the bar between the first and last handle."""
|
||||
|
||||
|
||||
class SliderLabel(QDoubleSpinBox):
|
||||
class SliderLabel(QLineEdit):
|
||||
def __init__(
|
||||
self,
|
||||
slider: QSlider,
|
||||
@@ -646,52 +668,139 @@ class SliderLabel(QDoubleSpinBox):
|
||||
) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self._slider = slider
|
||||
self._prefix = ""
|
||||
self._suffix = ""
|
||||
self._min = slider.minimum()
|
||||
self._max = slider.maximum()
|
||||
self._value = self._min
|
||||
self._callback = connect
|
||||
self._decimals = -1
|
||||
self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
|
||||
self.setMode(EdgeLabelMode.LabelIsValue)
|
||||
self.setDecimals(0)
|
||||
self.setText(str(self._value))
|
||||
validator = QDoubleValidator(self)
|
||||
validator.setNotation(QDoubleValidator.Notation.ScientificNotation)
|
||||
self.setValidator(validator)
|
||||
|
||||
self.setRange(slider.minimum(), slider.maximum())
|
||||
slider.rangeChanged.connect(self._update_size)
|
||||
self.setAlignment(alignment)
|
||||
self.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons)
|
||||
self.setStyleSheet("background:transparent; border: 0;")
|
||||
if connect is not None:
|
||||
self.editingFinished.connect(lambda: connect(self.value()))
|
||||
self.editingFinished.connect(self._editing_finished)
|
||||
self.editingFinished.connect(self._silent_clear_focus)
|
||||
self._update_size()
|
||||
|
||||
def _editing_finished(self):
|
||||
self._silent_clear_focus()
|
||||
self.setValue(float(self.text()))
|
||||
if self._callback:
|
||||
self._callback(self.value())
|
||||
|
||||
def setRange(self, min_: float, max_: float) -> None:
|
||||
if self._mode == EdgeLabelMode.LabelIsRange:
|
||||
max_val = max(abs(min_), abs(max_))
|
||||
n_digits = max(len(str(int(max_val))), 7)
|
||||
upper_bound = int("9" * n_digits)
|
||||
self._min = -upper_bound
|
||||
self._max = upper_bound
|
||||
self._update_size()
|
||||
else:
|
||||
max_ = max(max_, min_)
|
||||
self._min = min_
|
||||
self._max = max_
|
||||
|
||||
def setDecimals(self, prec: int) -> None:
|
||||
super().setDecimals(prec)
|
||||
# super().setDecimals(prec)
|
||||
self._decimals = prec
|
||||
self._update_size()
|
||||
|
||||
def decimals(self) -> int:
|
||||
"""Return the number of decimals used in the label."""
|
||||
return self._decimals
|
||||
|
||||
def value(self) -> float:
|
||||
return self._value
|
||||
|
||||
def setValue(self, val: Any) -> None:
|
||||
super().setValue(val)
|
||||
if val < self._min:
|
||||
val = self._min
|
||||
elif val > self._max:
|
||||
val = self._max
|
||||
self._value = val
|
||||
self.updateText()
|
||||
|
||||
def updateText(self) -> None:
|
||||
val = float(self._value)
|
||||
use_scientific = (abs(val) < 0.0001 or abs(val) > 9999999.0) and val != 0.0
|
||||
font_metrics = QFontMetrics(self.font())
|
||||
eight_len = _fm_width(font_metrics, "8")
|
||||
|
||||
available_chars = self.width() // eight_len
|
||||
|
||||
total, _fraction = f"{val:.<f}".split(".")
|
||||
|
||||
if len(total) > available_chars:
|
||||
use_scientific = True
|
||||
|
||||
if self._decimals < 0:
|
||||
if use_scientific:
|
||||
mantissa, exponent = f"{val:.{available_chars}e}".split("e")
|
||||
mantissa = mantissa.rstrip("0").rstrip(".")
|
||||
if len(mantissa) + len(exponent) + 1 < available_chars:
|
||||
text = f"{mantissa}e{exponent}"
|
||||
else:
|
||||
decimals = max(available_chars - len(exponent) - 3, 2)
|
||||
text = f"{val:.{decimals}e}"
|
||||
|
||||
else:
|
||||
decimals = max(available_chars - len(total) - 1, 2)
|
||||
text = f"{val:.{decimals}f}"
|
||||
text = text.rstrip("0").rstrip(".")
|
||||
else:
|
||||
if use_scientific:
|
||||
mantissa, exponent = f"{val:.{self._decimals}e}".split("e")
|
||||
mantissa = mantissa.rstrip("0").rstrip(".")
|
||||
text = f"{mantissa}e{exponent}"
|
||||
else:
|
||||
text = f"{val:.{self._decimals}f}"
|
||||
if text == "":
|
||||
text = "0"
|
||||
self.setText(text)
|
||||
if self._mode == EdgeLabelMode.LabelIsRange:
|
||||
self._update_size()
|
||||
|
||||
def setMaximum(self, max: float) -> None:
|
||||
super().setMaximum(max)
|
||||
if self._mode == EdgeLabelMode.LabelIsValue:
|
||||
self._update_size()
|
||||
def minimum(self):
|
||||
return self._min
|
||||
|
||||
def setMinimum(self, min: float) -> None:
|
||||
super().setMinimum(min)
|
||||
if self._mode == EdgeLabelMode.LabelIsValue:
|
||||
self._update_size()
|
||||
def setMaximum(self, max_: float) -> None:
|
||||
self.setRange(self._min, max_)
|
||||
|
||||
def maximum(self):
|
||||
return self._max
|
||||
|
||||
def setMinimum(self, min_: float) -> None:
|
||||
self.setRange(min_, self._max)
|
||||
|
||||
def setMode(self, opt: EdgeLabelMode) -> None:
|
||||
# when the edge labels are controlling slider range,
|
||||
# we want them to have a big range, but not have a huge label
|
||||
self._mode = opt
|
||||
if opt == EdgeLabelMode.LabelIsRange:
|
||||
self.setMinimum(-9999999)
|
||||
self.setMaximum(9999999)
|
||||
with contextlib.suppress(Exception):
|
||||
self._slider.rangeChanged.disconnect(self.setRange)
|
||||
else:
|
||||
self.setMinimum(self._slider.minimum())
|
||||
self.setMaximum(self._slider.maximum())
|
||||
self._slider.rangeChanged.connect(self.setRange)
|
||||
self.setRange(self._slider.minimum(), self._slider.maximum())
|
||||
self._update_size()
|
||||
|
||||
def prefix(self) -> str:
|
||||
return self._prefix
|
||||
|
||||
def setPrefix(self, prefix: str) -> None:
|
||||
self._prefix = prefix
|
||||
self._update_size()
|
||||
|
||||
def suffix(self) -> str:
|
||||
return self._suffix
|
||||
|
||||
def setSuffix(self, suffix: str) -> None:
|
||||
self._suffix = suffix
|
||||
self._update_size()
|
||||
|
||||
# --------------- private ----------------
|
||||
@@ -708,21 +817,19 @@ class SliderLabel(QDoubleSpinBox):
|
||||
|
||||
if self._mode & EdgeLabelMode.LabelIsValue:
|
||||
# determine width based on min/max/specialValue
|
||||
mintext = self.textFromValue(self.minimum())[:18]
|
||||
maxtext = self.textFromValue(self.maximum())[:18]
|
||||
mintext = str(self.minimum())[:18]
|
||||
maxtext = str(self.maximum())[:18]
|
||||
w = max(0, _fm_width(fm, mintext + fixed_content))
|
||||
w = max(w, _fm_width(fm, maxtext + fixed_content))
|
||||
if self.specialValueText():
|
||||
w = max(w, _fm_width(fm, self.specialValueText()))
|
||||
if self._mode & EdgeLabelMode.LabelIsRange:
|
||||
w += 8 # it seems as thought suffix() is not enough
|
||||
else:
|
||||
w = max(0, _fm_width(fm, self.textFromValue(self.value()))) + 3
|
||||
w = max(0, _fm_width(fm, str(self.value()))) + 3
|
||||
|
||||
w += 3 # cursor blinking space
|
||||
# get the final size hint
|
||||
opt = QStyleOptionSpinBox()
|
||||
self.initStyleOption(opt)
|
||||
# self.initStyleOption(opt)
|
||||
size = self.style().sizeFromContents(
|
||||
QStyle.ContentsType.CT_SpinBox, opt, QSize(w, h), self
|
||||
)
|
||||
|
@@ -10,14 +10,10 @@ class _IntMixin:
|
||||
self._singleStep = 1
|
||||
|
||||
def _type_cast(self, value) -> int:
|
||||
return int(round(value))
|
||||
return round(value)
|
||||
|
||||
|
||||
class _FloatMixin:
|
||||
_fvalueChanged = Signal(float)
|
||||
_fsliderMoved = Signal(float)
|
||||
_frangeChanged = Signal(float, float)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._singleStep = 0.01
|
||||
@@ -41,7 +37,9 @@ class QRangeSlider(_IntMixin, _GenericRangeSlider):
|
||||
|
||||
|
||||
class QDoubleRangeSlider(_FloatMixin, QRangeSlider):
|
||||
pass
|
||||
def _rename_signals(self) -> None:
|
||||
super()._rename_signals()
|
||||
self.rangeChanged = self.frangeChanged
|
||||
|
||||
|
||||
# QRangeSlider.__doc__ += "\n" + textwrap.indent(QSlider.__doc__, " ")
|
||||
|
3
src/superqt/switch/__init__.py
Normal file
3
src/superqt/switch/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from superqt.switch._toggle_switch import QStyleOptionToggleSwitch, QToggleSwitch
|
||||
|
||||
__all__ = ["QStyleOptionToggleSwitch", "QToggleSwitch"]
|
321
src/superqt/switch/_toggle_switch.py
Normal file
321
src/superqt/switch/_toggle_switch.py
Normal file
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import overload
|
||||
|
||||
from qtpy import QtCore, QtGui
|
||||
from qtpy import QtWidgets as QtW
|
||||
from qtpy.QtCore import Property, Qt
|
||||
|
||||
|
||||
class QStyleOptionToggleSwitch(QtW.QStyleOptionButton):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.on_color = QtGui.QColor("#4D79C7")
|
||||
self.off_color = QtGui.QColor("#909090")
|
||||
self.handle_color = QtGui.QColor("#d5d5d5")
|
||||
self.switch_width = 24
|
||||
self.switch_height = 12
|
||||
self.handle_size = 14
|
||||
|
||||
# these aren't yet overrideable in QToggleSwitch
|
||||
self.margin = 2
|
||||
self.text_alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||
|
||||
|
||||
class QToggleSwitch(QtW.QAbstractButton):
|
||||
StyleOption = QStyleOptionToggleSwitch
|
||||
|
||||
@overload
|
||||
def __init__(self, parent: QtW.QWidget | None = ...) -> None: ...
|
||||
@overload
|
||||
def __init__(self, text: str | None, parent: QtW.QWidget | None = ...) -> None: ...
|
||||
|
||||
def __init__( # type: ignore [misc] # overload
|
||||
self, text: str | None = None, parent: QtW.QWidget | None = None
|
||||
) -> None:
|
||||
if isinstance(text, QtW.QWidget):
|
||||
if parent is not None:
|
||||
raise TypeError("No overload of QToggleSwitch matches the arguments")
|
||||
parent = text
|
||||
text = None
|
||||
|
||||
# attributes for drawing the switch
|
||||
self._on_color = QtGui.QColor("#4D79C7")
|
||||
self._off_color = QtGui.QColor("#909090")
|
||||
self._handle_color = QtGui.QColor("#d5d5d5")
|
||||
self._switch_width = 24
|
||||
self._switch_height = 12
|
||||
self._handle_size = 14
|
||||
self._offset_value = 8.0
|
||||
|
||||
super().__init__(parent)
|
||||
self.setCheckable(True)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.toggled.connect(self._animate_handle)
|
||||
|
||||
self._anim = QtCore.QPropertyAnimation(self, b"_offset", self)
|
||||
self._anim.setDuration(120)
|
||||
self._offset_value = self._offset_for_checkstate(False)
|
||||
if text:
|
||||
self.setText(text)
|
||||
|
||||
def initStyleOption(self, option: QStyleOptionToggleSwitch) -> None:
|
||||
"""Initialize the style option for the switch."""
|
||||
option.initFrom(self)
|
||||
|
||||
option.text = self.text()
|
||||
option.icon = self.icon()
|
||||
option.iconSize = self.iconSize()
|
||||
option.state |= (
|
||||
QtW.QStyle.StateFlag.State_On
|
||||
if self.isChecked()
|
||||
else QtW.QStyle.StateFlag.State_Off
|
||||
)
|
||||
|
||||
option.on_color = self.onColor
|
||||
option.off_color = self.offColor
|
||||
option.handle_color = self.handleColor
|
||||
option.switch_width = self.switchWidth
|
||||
option.switch_height = self.switchHeight
|
||||
option.handle_size = self.handleSize
|
||||
|
||||
def paintEvent(self, a0: QtGui.QPaintEvent | None) -> None:
|
||||
p = QtGui.QPainter(self)
|
||||
opt = QStyleOptionToggleSwitch()
|
||||
self.initStyleOption(opt)
|
||||
self.drawGroove(p, self._groove_rect(opt), opt)
|
||||
p.save()
|
||||
self.drawHandle(p, self._handle_rect(opt), opt)
|
||||
p.restore()
|
||||
self.drawText(p, self._text_rect(opt), opt)
|
||||
p.end()
|
||||
|
||||
def minimumSizeHint(self) -> QtCore.QSize:
|
||||
return self.sizeHint()
|
||||
|
||||
def setAnimationDuration(self, msec: int) -> None:
|
||||
"""Set the duration of the animation in milliseconds.
|
||||
|
||||
To disable animation, set duration to 0.
|
||||
"""
|
||||
self._anim.setDuration(msec)
|
||||
|
||||
def animationDuration(self) -> int:
|
||||
"""Return the duration of the animation in milliseconds."""
|
||||
return self._anim.duration()
|
||||
|
||||
def sizeHint(self) -> QtCore.QSize:
|
||||
self.ensurePolished()
|
||||
opt = QStyleOptionToggleSwitch()
|
||||
self.initStyleOption(opt)
|
||||
|
||||
fm = QtGui.QFontMetrics(self.font())
|
||||
text_size = fm.size(0, self.text())
|
||||
height = max(opt.switch_height, text_size.height()) + opt.margin * 2
|
||||
width = opt.switch_width + text_size.width() + opt.margin * 2 + 8
|
||||
return QtCore.QSize(width, height)
|
||||
|
||||
### Re-implementable methods for drawing the switch ###
|
||||
|
||||
def drawGroove(
|
||||
self,
|
||||
painter: QtGui.QPainter,
|
||||
rect: QtCore.QRectF,
|
||||
option: QStyleOptionToggleSwitch,
|
||||
) -> None:
|
||||
"""Draw the groove of the switch.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
painter : QtGui.QPainter
|
||||
The painter to use for drawing.
|
||||
rect : QtCore.QRectF
|
||||
The rectangle in which to draw the groove.
|
||||
option : QStyleOptionToggleSwitch
|
||||
The style options used for drawing.
|
||||
"""
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
|
||||
is_checked = option.state & QtW.QStyle.StateFlag.State_On
|
||||
is_enabled = option.state & QtW.QStyle.StateFlag.State_Enabled
|
||||
# draw the groove
|
||||
if is_enabled:
|
||||
painter.setBrush(option.on_color if is_checked else option.off_color)
|
||||
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True)
|
||||
painter.setOpacity(0.8)
|
||||
else:
|
||||
painter.setBrush(option.off_color)
|
||||
painter.setOpacity(0.6)
|
||||
|
||||
half_height = option.switch_height / 2
|
||||
painter.drawRoundedRect(rect, half_height, half_height)
|
||||
|
||||
def drawHandle(
|
||||
self,
|
||||
painter: QtGui.QPainter,
|
||||
rect: QtCore.QRectF,
|
||||
option: QStyleOptionToggleSwitch,
|
||||
) -> None:
|
||||
"""Draw the handle of the switch.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
painter : QtGui.QPainter
|
||||
The painter to use for drawing.
|
||||
rect : QtCore.QRectF
|
||||
The rectangle in which to draw the handle.
|
||||
option : QStyleOptionToggleSwitch
|
||||
The style options used for drawing.
|
||||
"""
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(option.handle_color)
|
||||
painter.setOpacity(1.0)
|
||||
painter.drawEllipse(rect)
|
||||
|
||||
def drawText(
|
||||
self,
|
||||
painter: QtGui.QPainter,
|
||||
rect: QtCore.QRectF,
|
||||
option: QStyleOptionToggleSwitch,
|
||||
) -> None:
|
||||
"""Draw the text next to the switch.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
painter : QtGui.QPainter
|
||||
The painter to use for drawing.
|
||||
rect : QtCore.QRectF
|
||||
The rectangle in which to draw the text.
|
||||
option : QStyleOptionToggleSwitch
|
||||
The style options used for drawing.
|
||||
"""
|
||||
# TODO:
|
||||
# using self.style().drawControl(CE_PushButtonLabel ...)
|
||||
# might provide a more native experience.
|
||||
text_color = option.palette.color(self.foregroundRole())
|
||||
pen = QtGui.QPen(text_color, 1)
|
||||
painter.setPen(pen)
|
||||
painter.drawText(rect, int(option.text_alignment), option.text)
|
||||
|
||||
### Properties ###
|
||||
|
||||
def _get_onColor(self) -> QtGui.QColor:
|
||||
return self._on_color
|
||||
|
||||
def _set_onColor(self, color: QtGui.QColor | QtGui.QBrush) -> None:
|
||||
self._on_color = QtGui.QColor(color)
|
||||
self.update()
|
||||
|
||||
onColor = Property(QtGui.QColor, _get_onColor, _set_onColor)
|
||||
"""Color of the switch groove when it is on."""
|
||||
|
||||
def _get_offColor(self) -> QtGui.QColor:
|
||||
return self._off_color
|
||||
|
||||
def _set_offColor(self, color: QtGui.QColor | QtGui.QBrush) -> None:
|
||||
self._off_color = QtGui.QColor(color)
|
||||
self.update()
|
||||
|
||||
offColor = Property(QtGui.QColor, _get_offColor, _set_offColor)
|
||||
"""Color of the switch groove when it is off."""
|
||||
|
||||
def _get_handleColor(self) -> QtGui.QColor:
|
||||
return self._handle_color
|
||||
|
||||
def _set_handleColor(self, color: QtGui.QColor | QtGui.QBrush) -> None:
|
||||
self._handle_color = QtGui.QColor(color)
|
||||
self.update()
|
||||
|
||||
handleColor = Property(QtGui.QColor, _get_handleColor, _set_handleColor)
|
||||
"""Color of the switch handle."""
|
||||
|
||||
def _get_switchWidth(self) -> int:
|
||||
return self._switch_width
|
||||
|
||||
def _set_switchWidth(self, width: int) -> None:
|
||||
self._switch_width = width
|
||||
self._offset_value = self._offset_for_checkstate(self.isChecked())
|
||||
self.update()
|
||||
|
||||
switchWidth = Property(int, _get_switchWidth, _set_switchWidth)
|
||||
"""Width of the switch groove."""
|
||||
|
||||
def _get_switchHeight(self) -> int:
|
||||
return self._switch_height
|
||||
|
||||
def _set_switchHeight(self, height: int) -> None:
|
||||
self._switch_height = height
|
||||
self._offset_value = self._offset_for_checkstate(self.isChecked())
|
||||
self.update()
|
||||
|
||||
switchHeight = Property(int, _get_switchHeight, _set_switchHeight)
|
||||
"""Height of the switch groove."""
|
||||
|
||||
def _get_handleSize(self) -> int:
|
||||
return self._handle_size
|
||||
|
||||
def _set_handleSize(self, size: int) -> None:
|
||||
self._handle_size = size
|
||||
self.update()
|
||||
|
||||
handleSize = Property(int, _get_handleSize, _set_handleSize)
|
||||
"""Width/height of the switch handle."""
|
||||
|
||||
### Other private methods ###
|
||||
|
||||
def _animate_handle(self, val: bool) -> None:
|
||||
end = self._offset_for_checkstate(val)
|
||||
if self._anim.duration():
|
||||
self._anim.setStartValue(self._offset_for_checkstate(not val))
|
||||
self._anim.setEndValue(end)
|
||||
self._anim.start()
|
||||
else:
|
||||
self._set_offset(end)
|
||||
|
||||
def _get_offset(self) -> float:
|
||||
return self._offset_value
|
||||
|
||||
def _set_offset(self, offset: float) -> None:
|
||||
self._offset_value = offset
|
||||
self.update()
|
||||
|
||||
_offset = Property(float, _get_offset, _set_offset)
|
||||
|
||||
def _offset_for_checkstate(self, val: bool) -> float:
|
||||
opt = QStyleOptionToggleSwitch()
|
||||
self.initStyleOption(opt)
|
||||
if val:
|
||||
offset = opt.margin + opt.switch_width - opt.switch_height / 2
|
||||
else:
|
||||
offset = opt.margin + opt.switch_height / 2
|
||||
return offset
|
||||
|
||||
def _groove_rect(self, opt: QStyleOptionToggleSwitch) -> QtCore.QRectF:
|
||||
return QtCore.QRectF(
|
||||
opt.margin, self._vertical_offset(opt), opt.switch_width, opt.switch_height
|
||||
)
|
||||
|
||||
def _handle_rect(self, opt: QStyleOptionToggleSwitch) -> QtCore.QRectF:
|
||||
return QtCore.QRectF(
|
||||
self._offset_value - opt.handle_size / 2,
|
||||
self._vertical_offset(opt) - (opt.handle_size - opt.switch_height) / 2,
|
||||
opt.handle_size,
|
||||
opt.handle_size,
|
||||
)
|
||||
|
||||
def _text_rect(self, opt: QStyleOptionToggleSwitch) -> QtCore.QRectF:
|
||||
# If handle is bigger than groove, adjust the text to the right of the handle.
|
||||
# If groove is bigger, adjust the text to the right of the groove.
|
||||
return QtCore.QRectF(
|
||||
opt.switch_width
|
||||
+ max(opt.handle_size - opt.switch_height, 0) // 2
|
||||
+ opt.margin * 2
|
||||
+ 2,
|
||||
0,
|
||||
self.width() - opt.switch_width - opt.margin * 2,
|
||||
self.height(),
|
||||
)
|
||||
|
||||
def _vertical_offset(self, opt: QStyleOptionToggleSwitch) -> int:
|
||||
"""Offset for the vertical centering of the switch."""
|
||||
return (self.height() - opt.switch_height) // 2 + opt.margin
|
@@ -1,32 +1,34 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superqt.cmap import draw_colormap # noqa: TCH004
|
||||
from superqt.cmap import draw_colormap
|
||||
|
||||
__all__ = (
|
||||
"CodeSyntaxHighlight",
|
||||
"FunctionWorker",
|
||||
"GeneratorWorker",
|
||||
"QFlowLayout",
|
||||
"QMessageHandler",
|
||||
"QSignalDebouncer",
|
||||
"QSignalThrottler",
|
||||
"WorkerBase",
|
||||
"create_worker",
|
||||
"qimage_to_array",
|
||||
"draw_colormap",
|
||||
"ensure_main_thread",
|
||||
"ensure_object_thread",
|
||||
"exceptions_as_dialog",
|
||||
"FunctionWorker",
|
||||
"GeneratorWorker",
|
||||
"new_worker_qthread",
|
||||
"qdebounced",
|
||||
"QMessageHandler",
|
||||
"QSignalDebouncer",
|
||||
"QSignalThrottler",
|
||||
"qimage_to_array",
|
||||
"qthrottled",
|
||||
"signals_blocked",
|
||||
"thread_worker",
|
||||
"WorkerBase",
|
||||
)
|
||||
|
||||
from ._code_syntax_highlight import CodeSyntaxHighlight
|
||||
from ._ensure_thread import ensure_main_thread, ensure_object_thread
|
||||
from ._errormsg_context import exceptions_as_dialog
|
||||
from ._flow_layout import QFlowLayout
|
||||
from ._img_utils import qimage_to_array
|
||||
from ._message_handler import QMessageHandler
|
||||
from ._misc import signals_blocked
|
||||
|
@@ -1,75 +1,268 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from pygments import highlight
|
||||
from pygments.formatter import Formatter
|
||||
from pygments.lexers import find_lexer_class, get_lexer_by_name
|
||||
from pygments.util import ClassNotFound
|
||||
from qtpy import QtGui
|
||||
from qtpy.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
QPalette,
|
||||
QSyntaxHighlighter,
|
||||
QTextCharFormat,
|
||||
QTextDocument,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Literal, TypeAlias, TypedDict, Unpack
|
||||
|
||||
import pygments.style
|
||||
from pygments.style import _StyleDict
|
||||
from pygments.token import _TokenType
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
class SupportsDocumentAndPalette(QObject):
|
||||
def document(self) -> QTextDocument | None: ...
|
||||
def palette(self) -> QPalette: ...
|
||||
def setPalette(self, palette: QPalette) -> None: ...
|
||||
|
||||
KnownStyle: TypeAlias = Literal[
|
||||
"abap",
|
||||
"algol",
|
||||
"algol_nu",
|
||||
"arduino",
|
||||
"autumn",
|
||||
"bw",
|
||||
"borland",
|
||||
"coffee",
|
||||
"colorful",
|
||||
"default",
|
||||
"dracula",
|
||||
"emacs",
|
||||
"friendly_grayscale",
|
||||
"friendly",
|
||||
"fruity",
|
||||
"github-dark",
|
||||
"gruvbox-dark",
|
||||
"gruvbox-light",
|
||||
"igor",
|
||||
"inkpot",
|
||||
"lightbulb",
|
||||
"lilypond",
|
||||
"lovelace",
|
||||
"manni",
|
||||
"material",
|
||||
"monokai",
|
||||
"murphy",
|
||||
"native",
|
||||
"nord-darker",
|
||||
"nord",
|
||||
"one-dark",
|
||||
"paraiso-dark",
|
||||
"paraiso-light",
|
||||
"pastie",
|
||||
"perldoc",
|
||||
"rainbow_dash",
|
||||
"rrt",
|
||||
"sas",
|
||||
"solarized-dark",
|
||||
"solarized-light",
|
||||
"staroffice",
|
||||
"stata-dark",
|
||||
"stata-light",
|
||||
"tango",
|
||||
"trac",
|
||||
"vim",
|
||||
"vs",
|
||||
"xcode",
|
||||
"zenburn",
|
||||
]
|
||||
|
||||
class FormatterKwargs(TypedDict, total=False):
|
||||
style: KnownStyle | str
|
||||
full: bool
|
||||
title: str
|
||||
encoding: str
|
||||
outencoding: str
|
||||
|
||||
|
||||
MONO_FAMILIES = [
|
||||
"Menlo",
|
||||
"Courier New",
|
||||
"Courier",
|
||||
"Monaco",
|
||||
"Consolas",
|
||||
"Andale Mono",
|
||||
"Source Code Pro",
|
||||
"Ubuntu Mono",
|
||||
"monospace",
|
||||
]
|
||||
|
||||
|
||||
# inspired by https://github.com/Vector35/snippets/blob/master/QCodeEditor.py
|
||||
# (MIT license) and
|
||||
# https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter
|
||||
def get_text_char_format(style: _StyleDict) -> QTextCharFormat:
|
||||
"""Return a QTextCharFormat object based on the given Pygments `_StyleDict`.
|
||||
|
||||
|
||||
def get_text_char_format(style):
|
||||
text_char_format = QtGui.QTextCharFormat()
|
||||
if hasattr(text_char_format, "setFontFamilies"):
|
||||
text_char_format.setFontFamilies(["monospace"])
|
||||
else:
|
||||
text_char_format.setFontFamily("monospace")
|
||||
if style.get("color"):
|
||||
text_char_format.setForeground(QtGui.QColor(f"#{style['color']}"))
|
||||
|
||||
if style.get("bgcolor"):
|
||||
text_char_format.setBackground(QtGui.QColor(style["bgcolor"]))
|
||||
|
||||
style will likely have these keys:
|
||||
- color: str | None
|
||||
- bold: bool
|
||||
- italic: bool
|
||||
- underline: bool
|
||||
- bgcolor: str | None
|
||||
- border: str | None
|
||||
- roman: bool | None
|
||||
- sans: bool | None
|
||||
- mono: bool | None
|
||||
- ansicolor: str | None
|
||||
- bgansicolor: str | None
|
||||
"""
|
||||
text_char_format = QTextCharFormat()
|
||||
if style.get("mono"):
|
||||
text_char_format.setFontFamilies(MONO_FAMILIES)
|
||||
if color := style.get("color"):
|
||||
text_char_format.setForeground(QColor(f"#{color}"))
|
||||
if bgcolor := style.get("bgcolor"):
|
||||
text_char_format.setBackground(QColor(f"#{bgcolor}"))
|
||||
if style.get("bold"):
|
||||
text_char_format.setFontWeight(QtGui.QFont.Bold)
|
||||
text_char_format.setFontWeight(QFont.Weight.Bold)
|
||||
if style.get("italic"):
|
||||
text_char_format.setFontItalic(True)
|
||||
if style.get("underline"):
|
||||
text_char_format.setFontUnderline(True)
|
||||
|
||||
# TODO find if it is possible to support border style.
|
||||
|
||||
# if style.get("border"):
|
||||
# ...
|
||||
return text_char_format
|
||||
|
||||
|
||||
class QFormatter(Formatter):
|
||||
def __init__(self, **kwargs):
|
||||
def __init__(self, **kwargs: Unpack[FormatterKwargs]) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.data = []
|
||||
self._style = {name: get_text_char_format(style) for name, style in self.style}
|
||||
self.data: list[QTextCharFormat] = []
|
||||
style = cast("pygments.style.StyleMeta", self.style)
|
||||
self._style: Mapping[_TokenType, QTextCharFormat]
|
||||
self._style = {token: get_text_char_format(style) for token, style in style}
|
||||
|
||||
def format(self, tokensource, outfile):
|
||||
def format(
|
||||
self, tokensource: Sequence[tuple[_TokenType, str]], outfile: Any
|
||||
) -> None:
|
||||
"""Format the given token stream.
|
||||
|
||||
`outfile` is argument from parent class, but
|
||||
in Qt we do not produce string output, but QTextCharFormat, so it needs to be
|
||||
collected using `self.data`.
|
||||
When Qt calls the highlightBlock method on a `CodeSyntaxHighlight` object,
|
||||
`highlight(text, self.lexer, self.formatter)`, which trigger pygments to call
|
||||
this method.
|
||||
|
||||
Normally, this method puts output into `outfile`, but in Qt we do not produce
|
||||
string output; instead we collect QTextCharFormat objects in `self.data`, which
|
||||
can be used to apply formatting in the `highlightBlock` method that triggered
|
||||
this method.
|
||||
"""
|
||||
self.data = []
|
||||
|
||||
null = QTextCharFormat()
|
||||
for token, value in tokensource:
|
||||
self.data.extend([self._style[token]] * len(value))
|
||||
# using get method to workaround not defined style for plain token
|
||||
# https://github.com/pygments/pygments/issues/2149
|
||||
self.data.extend([self._style.get(token, null)] * len(value))
|
||||
|
||||
|
||||
class CodeSyntaxHighlight(QtGui.QSyntaxHighlighter):
|
||||
def __init__(self, parent, lang, theme):
|
||||
class CodeSyntaxHighlight(QSyntaxHighlighter):
|
||||
"""A syntax highlighter for code using Pygments.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
parent : QTextDocument | QObject | None
|
||||
The parent object. Usually a QTextDocument. To use this class with a
|
||||
QTextArea, pass in `text_area.document()`.
|
||||
lang : str
|
||||
The language of the code to highlight. This should be a string that
|
||||
Pygments recognizes, e.g. 'python', 'pytb', 'cpp', 'java', etc.
|
||||
theme : KnownStyle | str
|
||||
The name of the Pygments style to use. For a complete list of available
|
||||
styles, use `pygments.styles.get_all_styles()`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```python
|
||||
from qtpy.QtWidgets import QTextEdit
|
||||
from superqt.utils import CodeSyntaxHighlight
|
||||
|
||||
text_area = QTextEdit()
|
||||
highlighter = CodeSyntaxHighlight(text_area.document(), "python", "monokai")
|
||||
|
||||
# then manually apply the background color to the text area.
|
||||
palette = text_area.palette()
|
||||
bgrd_color = QColor(self._highlight.background_color)
|
||||
palette.setColor(QPalette.ColorRole.Base, bgrd_color)
|
||||
text_area.setPalette(palette)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: SupportsDocumentAndPalette | QTextDocument | QObject | None,
|
||||
lang: str,
|
||||
theme: KnownStyle | str = "default",
|
||||
) -> None:
|
||||
self._doc_parent: SupportsDocumentAndPalette | None = None
|
||||
if (
|
||||
parent
|
||||
and not isinstance(parent, QTextDocument)
|
||||
and hasattr(parent, "document")
|
||||
and callable(parent.document)
|
||||
and isinstance(doc := parent.document(), QTextDocument)
|
||||
):
|
||||
if hasattr(parent, "palette") and hasattr(parent, "setPalette"):
|
||||
self._doc_parent = cast("SupportsDocumentAndPalette", parent)
|
||||
parent = doc
|
||||
|
||||
super().__init__(parent)
|
||||
self.setLanguage(lang)
|
||||
self.setTheme(theme)
|
||||
|
||||
def setTheme(self, theme: KnownStyle | str) -> None:
|
||||
"""Set the theme for the syntax highlighting.
|
||||
|
||||
This should be a string that Pygments recognizes, e.g. 'monokai', 'solarized'.
|
||||
Use `pygments.styles.get_all_styles()` to see a list of available styles.
|
||||
"""
|
||||
self.formatter = QFormatter(style=theme)
|
||||
if self._doc_parent is not None:
|
||||
palette = self._doc_parent.palette()
|
||||
bgrd = QColor(self.background_color)
|
||||
palette.setColor(QPalette.ColorRole.Base, bgrd)
|
||||
self._doc_parent.setPalette(palette)
|
||||
|
||||
self.rehighlight()
|
||||
|
||||
def setLanguage(self, lang: str) -> None:
|
||||
"""Set the language for the syntax highlighting.
|
||||
|
||||
This should be a string that Pygments recognizes, e.g. 'python', 'pytb', 'cpp',
|
||||
'java', etc.
|
||||
"""
|
||||
try:
|
||||
self.lexer = get_lexer_by_name(lang)
|
||||
except ClassNotFound:
|
||||
self.lexer = find_lexer_class(lang)()
|
||||
except ClassNotFound as e:
|
||||
if cls := find_lexer_class(lang):
|
||||
self.lexer = cls()
|
||||
else:
|
||||
raise ValueError(f"Could not find lexer for language {lang!r}.") from e
|
||||
|
||||
@property
|
||||
def background_color(self):
|
||||
return self.formatter.style.background_color
|
||||
def background_color(self) -> str:
|
||||
style = cast("pygments.style.StyleMeta", self.formatter.style)
|
||||
return style.background_color
|
||||
|
||||
def highlightBlock(self, text):
|
||||
def highlightBlock(self, text: str | None) -> None:
|
||||
# dirty, dirty hack
|
||||
# The core problem is that pygemnts by default use string streams,
|
||||
# The core problem is that pygments by default use string streams,
|
||||
# that will not handle QTextCharFormat, so we need use `data` property to
|
||||
# work around this.
|
||||
highlight(text, self.lexer, self.formatter)
|
||||
for i in range(len(text)):
|
||||
self.setFormat(i, 1, self.formatter.data[i])
|
||||
if text:
|
||||
highlight(text, self.lexer, self.formatter)
|
||||
for i in range(len(text)):
|
||||
self.setFormat(i, 1, self.formatter.data[i])
|
||||
|
183
src/superqt/utils/_flow_layout.py
Normal file
183
src/superqt/utils/_flow_layout.py
Normal file
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QPoint, QRect, QSize, Qt
|
||||
from qtpy.QtWidgets import QLayout, QLayoutItem, QSizePolicy, QStyle, QWidget
|
||||
|
||||
|
||||
class QFlowLayout(QLayout):
|
||||
"""Layout that handles different window sizes.
|
||||
|
||||
The widget placement changes depending on the width of the application window.
|
||||
|
||||
Code translated from C++ at:
|
||||
<https://code.qt.io/cgit/qt/qtbase.git/tree/examples/widgets/layouts/flowlayout>
|
||||
|
||||
described at: <https://doc.qt.io/qt-6/qtwidgets-layouts-flowlayout-example.html>
|
||||
|
||||
See also: <https://doc.qt.io/qt-6/layout.html>
|
||||
|
||||
Parameters
|
||||
----------
|
||||
parent : QWidget, optional
|
||||
The parent widget, by default None
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
self._item_list: list[QLayoutItem] = []
|
||||
self._h_space = -1
|
||||
self._v_space = -1
|
||||
|
||||
def __del__(self) -> None:
|
||||
while item := self.takeAt(0):
|
||||
del item
|
||||
|
||||
def addItem(self, item: QLayoutItem | None) -> None:
|
||||
"""Add an item to the layout."""
|
||||
if item:
|
||||
self._item_list.append(item)
|
||||
|
||||
def setHorizontalSpacing(self, space: int | None) -> None:
|
||||
"""Set the horizontal spacing.
|
||||
|
||||
If None or -1, the spacing is set to the default value based on the style
|
||||
of the parent widget.
|
||||
"""
|
||||
self._h_space = -1 if space is None else space
|
||||
|
||||
def horizontalSpacing(self) -> int:
|
||||
"""Return the horizontal spacing."""
|
||||
if self._h_space >= 0:
|
||||
return self._h_space
|
||||
return self._smartSpacing(QStyle.PixelMetric.PM_LayoutHorizontalSpacing)
|
||||
|
||||
def setVerticalSpacing(self, space: int | None) -> None:
|
||||
"""Set the vertical spacing.
|
||||
|
||||
If None or -1, the spacing is set to the default value based on the style
|
||||
of the parent widget.
|
||||
"""
|
||||
self._v_space = -1 if space is None else space
|
||||
|
||||
def verticalSpacing(self) -> int:
|
||||
"""Return the vertical spacing."""
|
||||
if self._v_space >= 0:
|
||||
return self._v_space
|
||||
return self._smartSpacing(QStyle.PixelMetric.PM_LayoutVerticalSpacing)
|
||||
|
||||
def expandingDirections(self) -> Qt.Orientation:
|
||||
"""Return the expanding directions.
|
||||
|
||||
These are the Qt::Orientations in which the layout can make use of more space
|
||||
than its sizeHint().
|
||||
"""
|
||||
return Qt.Orientation.Horizontal
|
||||
|
||||
def hasHeightForWidth(self) -> bool:
|
||||
"""Return whether the layout handles height for width."""
|
||||
return True
|
||||
|
||||
def heightForWidth(self, width: int) -> int:
|
||||
"""Return the height for a given width.
|
||||
|
||||
`heightForWidth()` passes the width on to _doLayout() which in turn uses the
|
||||
width as an argument for the layout rect, i.e., the bounds in which the items
|
||||
are laid out. This rect does not include the layout margin().
|
||||
"""
|
||||
return self._doLayout(QRect(0, 0, width, 0), True)
|
||||
|
||||
def count(self) -> int:
|
||||
"""Return the number of items in the layout."""
|
||||
return len(self._item_list)
|
||||
|
||||
def itemAt(self, index: int) -> QLayoutItem | None:
|
||||
"""Return the item at the given index, or None if the index is out of range."""
|
||||
try:
|
||||
return self._item_list[index]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
def minimumSize(self) -> QSize:
|
||||
"""Return the minimum size of the layout."""
|
||||
size = QSize()
|
||||
for item in self._item_list:
|
||||
size = size.expandedTo(item.minimumSize())
|
||||
|
||||
margins = self.contentsMargins()
|
||||
size += QSize(
|
||||
margins.left() + margins.right(), margins.top() + margins.bottom()
|
||||
)
|
||||
return size
|
||||
|
||||
def setGeometry(self, rect: QRect) -> None:
|
||||
"""Set the geometry of the layout.
|
||||
|
||||
This triggers a re-layout of the items.
|
||||
"""
|
||||
super().setGeometry(rect)
|
||||
self._doLayout(rect)
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
"""Return the size hint of the layout."""
|
||||
return self.minimumSize()
|
||||
|
||||
def takeAt(self, index: int) -> QLayoutItem | None:
|
||||
"""Remove and return the item at the given index. Or return None."""
|
||||
if 0 <= index < len(self._item_list):
|
||||
return self._item_list.pop(index)
|
||||
return None
|
||||
|
||||
def _doLayout(self, rect: QRect, test_only: bool = False) -> int:
|
||||
"""Arrange the items in the layout.
|
||||
|
||||
If test_only is True, the items are not actually laid out, but the height
|
||||
that the layout would have with the given width is returned.
|
||||
"""
|
||||
left, top, right, bottom = self.getContentsMargins()
|
||||
effective_rect = rect.adjusted(left, top, -right, -bottom) # type: ignore
|
||||
x = effective_rect.x()
|
||||
y = effective_rect.y()
|
||||
line_height = 0
|
||||
|
||||
for item in self._item_list:
|
||||
if (wid := item.widget()) and (style := wid.style()):
|
||||
space_x = self.horizontalSpacing()
|
||||
space_y = self.verticalSpacing()
|
||||
if space_x == -1:
|
||||
space_x = style.layoutSpacing(
|
||||
QSizePolicy.ControlType.PushButton,
|
||||
QSizePolicy.ControlType.PushButton,
|
||||
Qt.Orientation.Horizontal,
|
||||
)
|
||||
if space_y == -1:
|
||||
space_y = style.layoutSpacing(
|
||||
QSizePolicy.ControlType.PushButton,
|
||||
QSizePolicy.ControlType.PushButton,
|
||||
Qt.Orientation.Vertical,
|
||||
)
|
||||
|
||||
# next_x is the x-coordinate of the right edge of the item
|
||||
next_x = x + item.sizeHint().width() + space_x
|
||||
# if the item is not the first one in a line, add the spacing
|
||||
# to the left of it
|
||||
if next_x - space_x > effective_rect.right() and line_height > 0:
|
||||
x = effective_rect.x()
|
||||
y = y + line_height + space_y
|
||||
next_x = x + item.sizeHint().width() + space_x
|
||||
line_height = 0
|
||||
|
||||
# if this is not a test run, move the item to its proper place
|
||||
if not test_only:
|
||||
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
|
||||
|
||||
x = next_x
|
||||
line_height = max(line_height, item.sizeHint().height())
|
||||
|
||||
return y + line_height - rect.y() + bottom
|
||||
|
||||
def _smartSpacing(self, pm: QStyle.PixelMetric) -> int:
|
||||
"""Return the smart spacing based on the style of the parent widget."""
|
||||
if isinstance(parent := self.parent(), QWidget) and (style := parent.style()):
|
||||
return style.pixelMetric(pm, None, parent)
|
||||
return -1
|
@@ -1,3 +1,4 @@
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtGui import QImage
|
||||
@@ -37,4 +38,8 @@ def qimage_to_array(img: QImage) -> "np.ndarray":
|
||||
arr = np.frombuffer(b, np.uint8).reshape(h, w, c)
|
||||
|
||||
# reverse channel colors for numpy
|
||||
return arr.take([2, 1, 0, 3], axis=2)
|
||||
# On big endian we need to specify a different order
|
||||
if sys.byteorder == "big":
|
||||
return arr.take([1, 2, 3, 0], axis=2) # pragma: no cover
|
||||
else:
|
||||
return arr.take([2, 1, 0, 3], axis=2)
|
||||
|
@@ -1,5 +1,6 @@
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Iterator
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from qtpy.QtCore import QObject
|
||||
|
@@ -9,9 +9,7 @@ from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
ClassVar,
|
||||
Generator,
|
||||
Generic,
|
||||
Sequence,
|
||||
TypeVar,
|
||||
overload,
|
||||
)
|
||||
@@ -19,6 +17,8 @@ from typing import (
|
||||
from qtpy.QtCore import QObject, QRunnable, QThread, QThreadPool, QTimer, Signal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator, Sequence
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
class SigInst(Generic[_T]):
|
||||
@@ -766,7 +766,7 @@ def thread_worker(
|
||||
############################################################################
|
||||
|
||||
# This is a variant on the above pattern, it uses QThread instead of Qrunnable
|
||||
# see https://doc.qt.io/qt-5/threads-technologies.html#comparison-of-solutions
|
||||
# see https://doc.qt.io/qt-6/threads-technologies.html#comparison-of-solutions
|
||||
# (it appears from that table that QRunnable cannot emit or receive signals,
|
||||
# but we circumvent that here with our WorkerBase class that also inherits from
|
||||
# QObject... providing signals/slots).
|
||||
@@ -777,7 +777,7 @@ def thread_worker(
|
||||
#
|
||||
# However, a disadvantage is that you have no access to (and therefore less
|
||||
# control over) the QThread itself. See for example all of the methods
|
||||
# provided on the QThread object: https://doc.qt.io/qt-5/qthread.html
|
||||
# provided on the QThread object: https://doc.qt.io/qt-6/qthread.html
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -808,7 +808,7 @@ def new_worker_qthread(
|
||||
standard "single-threaded" signals & slots, note that inter-thread
|
||||
signals and slots (automatically) use an event-based QueuedConnection, while
|
||||
intra-thread signals use a DirectConnection. See [Signals and Slots Across
|
||||
Threads](https://doc.qt.io/qt-5/threads-qobject.html#signals-and-slots-across-threads>)
|
||||
Threads](https://doc.qt.io/qt-6/threads-qobject.html#signals-and-slots-across-threads>)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
|
@@ -76,8 +76,9 @@ def test_catalog_combo(qtbot):
|
||||
assert wdg.currentColormap() == Colormap("viridis")
|
||||
|
||||
|
||||
def test_cmap_combo(qtbot):
|
||||
wdg = QColormapComboBox(allow_user_colormaps=True)
|
||||
@pytest.mark.parametrize("filterable", [False, True])
|
||||
def test_cmap_combo(qtbot, filterable):
|
||||
wdg = QColormapComboBox(allow_user_colormaps=True, filterable=filterable)
|
||||
qtbot.addWidget(wdg)
|
||||
wdg.show()
|
||||
assert wdg.userAdditionsAllowed()
|
||||
|
@@ -69,3 +69,14 @@ def test_wrap_text():
|
||||
assert isinstance(wrap, list)
|
||||
assert all(isinstance(x, str) for x in wrap)
|
||||
assert 9 <= len(wrap) <= 13
|
||||
|
||||
|
||||
def test_minimum_size_hint():
|
||||
# The hint should always just be the space needed for "..."
|
||||
wdg = QElidingLabel()
|
||||
size_hint = wdg.minimumSizeHint()
|
||||
# Regardless of what text is contained
|
||||
wdg.setText(TEXT)
|
||||
new_hint = wdg.minimumSizeHint()
|
||||
assert size_hint.width() == new_hint.width()
|
||||
assert size_hint.height() == new_hint.height()
|
||||
|
@@ -162,7 +162,7 @@ def test_names(qapp):
|
||||
signature = inspect.signature(ob.check_object_thread_return_future)
|
||||
assert len(signature.parameters) == 1
|
||||
assert next(iter(signature.parameters.values())).name == "a"
|
||||
assert next(iter(signature.parameters.values())).annotation == int
|
||||
assert next(iter(signature.parameters.values())).annotation is int
|
||||
assert ob.check_main_thread_return.__name__ == "check_main_thread_return"
|
||||
|
||||
|
||||
|
27
tests/test_flow_layout.py
Normal file
27
tests/test_flow_layout.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from typing import Any
|
||||
|
||||
from qtpy.QtWidgets import QPushButton, QWidget
|
||||
|
||||
from superqt import QFlowLayout
|
||||
|
||||
|
||||
def test_flow_layout(qtbot: Any) -> None:
|
||||
wdg = QWidget()
|
||||
qtbot.addWidget(wdg)
|
||||
|
||||
layout = QFlowLayout(wdg)
|
||||
layout.addWidget(QPushButton("Short"))
|
||||
layout.addWidget(QPushButton("Longer"))
|
||||
layout.addWidget(QPushButton("Different text"))
|
||||
layout.addWidget(QPushButton("More text"))
|
||||
layout.addWidget(QPushButton("Even longer button text"))
|
||||
|
||||
wdg.setWindowTitle("Flow Layout")
|
||||
wdg.show()
|
||||
|
||||
assert layout.expandingDirections()
|
||||
assert layout.heightForWidth(200) > layout.heightForWidth(400)
|
||||
assert layout.count() == 5
|
||||
assert layout.itemAt(0).widget().text() == "Short"
|
||||
layout.takeAt(0)
|
||||
assert layout.count() == 4
|
@@ -1,5 +1,3 @@
|
||||
from typing import List, Tuple
|
||||
|
||||
import pytest
|
||||
from pytestqt.qtbot import QtBot
|
||||
from qtpy.QtCore import Qt
|
||||
@@ -30,15 +28,15 @@ def widget(qtbot: QtBot, data: dict) -> QSearchableTreeWidget:
|
||||
return widget
|
||||
|
||||
|
||||
def columns(item: QTreeWidgetItem) -> Tuple[str, str]:
|
||||
def columns(item: QTreeWidgetItem) -> tuple[str, str]:
|
||||
return item.text(0), item.text(1)
|
||||
|
||||
|
||||
def all_items(tree: QTreeWidget) -> List[QTreeWidgetItem]:
|
||||
def all_items(tree: QTreeWidget) -> list[QTreeWidgetItem]:
|
||||
return tree.findItems("", Qt.MatchContains | Qt.MatchRecursive)
|
||||
|
||||
|
||||
def shown_items(tree: QTreeWidget) -> List[QTreeWidgetItem]:
|
||||
def shown_items(tree: QTreeWidget) -> list[QTreeWidgetItem]:
|
||||
items = all_items(tree)
|
||||
return [item for item in items if not item.isHidden()]
|
||||
|
||||
|
@@ -125,7 +125,7 @@ def test_debouncer_method_definition(qtbot):
|
||||
|
||||
def test_class_with_slots(qtbot):
|
||||
class A:
|
||||
__slots__ = ("count", "__weakref__")
|
||||
__slots__ = ("__weakref__", "count")
|
||||
|
||||
def __init__(self):
|
||||
self.count = 0
|
||||
|
116
tests/test_toggle_switch.py
Normal file
116
tests/test_toggle_switch.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication, QCheckBox, QVBoxLayout, QWidget
|
||||
|
||||
from superqt import QToggleSwitch
|
||||
|
||||
|
||||
def test_on_and_off(qtbot):
|
||||
wdg = QToggleSwitch()
|
||||
qtbot.addWidget(wdg)
|
||||
wdg.show()
|
||||
assert not wdg.isChecked()
|
||||
wdg.setChecked(True)
|
||||
assert wdg.isChecked()
|
||||
QApplication.processEvents()
|
||||
wdg.setChecked(False)
|
||||
assert not wdg.isChecked()
|
||||
QApplication.processEvents()
|
||||
wdg.setChecked(False)
|
||||
assert not wdg.isChecked()
|
||||
wdg.toggle()
|
||||
assert wdg.isChecked()
|
||||
wdg.toggle()
|
||||
assert not wdg.isChecked()
|
||||
wdg.click()
|
||||
assert wdg.isChecked()
|
||||
wdg.click()
|
||||
assert not wdg.isChecked()
|
||||
QApplication.processEvents()
|
||||
|
||||
|
||||
def test_get_set(qtbot):
|
||||
wdg = QToggleSwitch()
|
||||
qtbot.addWidget(wdg)
|
||||
wdg.onColor = "#ff0000"
|
||||
assert wdg.onColor.name() == "#ff0000"
|
||||
wdg.offColor = "#00ff00"
|
||||
assert wdg.offColor.name() == "#00ff00"
|
||||
wdg.handleColor = "#0000ff"
|
||||
assert wdg.handleColor.name() == "#0000ff"
|
||||
wdg.setText("new text")
|
||||
assert wdg.text() == "new text"
|
||||
wdg.switchWidth = 100
|
||||
assert wdg.switchWidth == 100
|
||||
wdg.switchHeight = 100
|
||||
assert wdg.switchHeight == 100
|
||||
wdg.handleSize = 80
|
||||
assert wdg.handleSize == 80
|
||||
|
||||
|
||||
def test_mouse_click(qtbot):
|
||||
wdg = QToggleSwitch()
|
||||
mock = Mock()
|
||||
wdg.toggled.connect(mock)
|
||||
qtbot.addWidget(wdg)
|
||||
assert not wdg.isChecked()
|
||||
mock.assert_not_called()
|
||||
qtbot.mouseClick(wdg, Qt.MouseButton.LeftButton)
|
||||
assert wdg.isChecked()
|
||||
mock.assert_called_once_with(True)
|
||||
qtbot.mouseClick(wdg, Qt.MouseButton.LeftButton)
|
||||
assert not wdg.isChecked()
|
||||
|
||||
|
||||
def test_signal_emission_order(qtbot):
|
||||
"""Check if event emmision is same for QToggleSwitch and QCheckBox"""
|
||||
wdg = QToggleSwitch()
|
||||
emitted_from_toggleswitch = []
|
||||
wdg.toggled.connect(lambda: emitted_from_toggleswitch.append("toggled"))
|
||||
wdg.pressed.connect(lambda: emitted_from_toggleswitch.append("pressed"))
|
||||
wdg.clicked.connect(lambda: emitted_from_toggleswitch.append("clicked"))
|
||||
wdg.released.connect(lambda: emitted_from_toggleswitch.append("released"))
|
||||
qtbot.addWidget(wdg)
|
||||
|
||||
checkbox = QCheckBox()
|
||||
emitted_from_checkbox = []
|
||||
checkbox.toggled.connect(lambda: emitted_from_checkbox.append("toggled"))
|
||||
checkbox.pressed.connect(lambda: emitted_from_checkbox.append("pressed"))
|
||||
checkbox.clicked.connect(lambda: emitted_from_checkbox.append("clicked"))
|
||||
checkbox.released.connect(lambda: emitted_from_checkbox.append("released"))
|
||||
qtbot.addWidget(checkbox)
|
||||
|
||||
emitted_from_toggleswitch.clear()
|
||||
emitted_from_checkbox.clear()
|
||||
wdg.toggle()
|
||||
checkbox.toggle()
|
||||
assert emitted_from_toggleswitch
|
||||
assert emitted_from_toggleswitch == emitted_from_checkbox
|
||||
|
||||
emitted_from_toggleswitch.clear()
|
||||
emitted_from_checkbox.clear()
|
||||
wdg.click()
|
||||
checkbox.click()
|
||||
assert emitted_from_toggleswitch
|
||||
assert emitted_from_toggleswitch == emitted_from_checkbox
|
||||
|
||||
|
||||
def test_multiple_lines(qtbot):
|
||||
container = QWidget()
|
||||
layout = QVBoxLayout(container)
|
||||
wdg0 = QToggleSwitch("line1\nline2\nline3")
|
||||
wdg1 = QToggleSwitch("line1\nline2")
|
||||
checkbox = QCheckBox()
|
||||
layout.addWidget(wdg0)
|
||||
layout.addWidget(wdg1)
|
||||
layout.addWidget(checkbox)
|
||||
container.show()
|
||||
qtbot.addWidget(container)
|
||||
|
||||
assert wdg0.text() == "line1\nline2\nline3"
|
||||
assert wdg1.text() == "line1\nline2"
|
||||
assert wdg0.sizeHint().height() > wdg1.sizeHint().height()
|
||||
assert wdg1.sizeHint().height() > checkbox.sizeHint().height()
|
||||
assert wdg0.height() > wdg1.height()
|
||||
assert wdg1.height() > checkbox.height()
|
@@ -1,4 +1,5 @@
|
||||
from typing import Any, Iterable
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import math
|
||||
from collections.abc import Iterable
|
||||
from itertools import product
|
||||
from typing import Any, Iterable
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
Reference in New Issue
Block a user