Compare commits

...

11 Commits

Author SHA1 Message Date
Talley Lambert
c0c3a387bb chore: changelog v0.7.3 2025-03-28 15:18:47 -04:00
Hanjin Liu
5ce74b8198 feat: toggle switch (#284)
* implement toggle switch

* rename, inherit QCheckBox

* fix pyside6

* reimplement with QAbstractButton

* refactor methods

* fix sizeHint

* suggestions

* make sizes customizable

* parse as int

* Add doc to test function

Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com>

---------

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com>
2025-03-28 15:09:59 -04:00
Peter Sobolewski
0b2602b460 Enh: Adds a filterable kwarg to ColormapComboBox enabling filtering (like Catalog) (#278) 2025-03-17 19:16:44 -04:00
Talley Lambert
f9bc334228 chore: changelog v0.7.2 2025-03-17 08:53:11 -04:00
Talley Lambert
55732afa71 fix: less Slider signal renaming, make alternate signal types public (#283)
* fix: less signal renaming

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

* lint

* more renames

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

* warn napari

* lint

* add comment

* remove napari getattr

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

* add back values changed

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-03-17 08:51:50 -04:00
pre-commit-ci[bot]
22372f58a4 ci: [pre-commit.ci] autoupdate (#282)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.4 → v0.9.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.4...v0.9.9)
- [github.com/pre-commit/mirrors-mypy: v1.14.1 → v1.15.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.14.1...v1.15.0)

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-03-16 11:35:09 -04:00
pre-commit-ci[bot]
e990284bd1 ci: [pre-commit.ci] autoupdate (#279)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.8.6 → v0.9.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.6...v0.9.4)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-02-21 13:05:43 -05:00
Peter Sobolewski
7850e53b61 Update CONTRIBUTING.md to include [test] and mention Qt backend (#276)
* Update CONTRIBUTING.md to include [test] and mention Qt backend

* add superqt[test,pyqt6] to dev, mention it in contributing guide
2025-01-26 16:15:57 -05:00
Peter Sobolewski
68bafaceaa Update CONTRIBUTING.md to install .[dev] first then pre-commit (#275) 2025-01-26 14:34:22 -05:00
pre-commit-ci[bot]
0b1cd1b11a ci: [pre-commit.ci] autoupdate (#272)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.8.1 → v0.8.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.1...v0.8.6)
- [github.com/pre-commit/mirrors-mypy: v1.13.0 → v1.14.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.13.0...v1.14.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-01-24 13:50:58 -05:00
Talley Lambert
646cb4ea48 docs: add iconify docs 2025-01-05 17:12:37 -05:00
21 changed files with 785 additions and 70 deletions

View File

@@ -5,7 +5,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.1
rev: v0.9.9
hooks:
- id: ruff
args: [--fix, --unsafe-fixes]
@@ -17,7 +17,7 @@ repos:
- id: validate-pyproject
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
rev: v1.15.0
hooks:
- id: mypy
exclude: tests|examples

View File

@@ -1,5 +1,30 @@
# Changelog
## [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)

View File

@@ -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
```

36
docs/utilities/iconify.md Normal file
View 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

View File

@@ -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 |

67
examples/toggle_switch.py Normal file
View 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()

View File

@@ -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: "#"

View File

@@ -65,13 +65,14 @@ dev = [
"pydocstyle",
"rich",
"types-Pygments",
"superqt[test,pyqt6]",
]
docs = [
"mkdocs-macros-plugin",
"mkdocs-material",
"mkdocstrings[python]",
"pint",
"cmap",
"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]",
]
quantity = ["pint"]
cmap = ["cmap >=0.1.1"]

View File

@@ -22,6 +22,7 @@ from .sliders import (
QRangeSlider,
)
from .spinbox import QLargeIntSpinBox
from .switch import QToggleSwitch
from .utils import (
QFlowLayout,
QMessageHandler,
@@ -51,14 +52,15 @@ __all__ = [
"QSearchableComboBox",
"QSearchableListWidget",
"QSearchableTreeWidget",
"QToggleSwitch",
"ensure_main_thread",
"ensure_object_thread",
]
if TYPE_CHECKING:
from .combobox import QColormapComboBox # noqa: TC004
from .iconify import QIconifyIcon # noqa: TC004
from .spinbox._quantity import QQuantity # noqa: TC004
from .combobox import QColormapComboBox
from .iconify import QIconifyIcon
from .spinbox._quantity import QQuantity
def __getattr__(name: str) -> Any:

View File

@@ -3,11 +3,12 @@ from __future__ import annotations
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,
@@ -26,6 +27,7 @@ if TYPE_CHECKING:
from collections.abc import Sequence
from cmap._colormap import ColorStopsLike
from qtpy.QtGui import QKeyEvent
CMAP_ROLE = Qt.ItemDataRole.UserRole + 1
@@ -45,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)
@@ -55,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
@@ -75,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
@@ -96,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."""
@@ -124,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."""
@@ -173,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")
@@ -220,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()

View File

@@ -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

View File

@@ -13,7 +13,7 @@ __all__ = (
if TYPE_CHECKING:
from superqt.cmap import QColormapComboBox # noqa: TC004
from superqt.cmap import QColormapComboBox
def __getattr__(name: str) -> Any: # pragma: no cover

View File

@@ -29,19 +29,21 @@ 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]
@@ -64,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:

View File

@@ -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.

View File

@@ -162,9 +162,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
@@ -283,18 +280,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: ...
@@ -313,9 +307,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()
@@ -325,9 +319,7 @@ class QLabeledDoubleSlider(QLabeledSlider):
class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
_valueChanged = Signal(tuple)
_sliderPressed = Signal()
_sliderReleased = Signal()
valuesChanged = Signal(tuple)
editingFinished = Signal()
_slider_class = QRangeSlider
@@ -359,7 +351,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,
@@ -492,9 +484,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 (
@@ -602,7 +592,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: ...
@@ -618,7 +608,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()

View File

@@ -14,10 +14,6 @@ class _IntMixin:
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__, " ")

View File

@@ -0,0 +1,3 @@
from superqt.switch._toggle_switch import QStyleOptionToggleSwitch, QToggleSwitch
__all__ = ["QStyleOptionToggleSwitch", "QToggleSwitch"]

View 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

View File

@@ -1,7 +1,7 @@
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from superqt.cmap import draw_colormap # noqa: TC004
from superqt.cmap import draw_colormap
__all__ = (
"CodeSyntaxHighlight",

View File

@@ -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()

116
tests/test_toggle_switch.py Normal file
View 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()