diff --git a/pyproject.toml b/pyproject.toml index a99286a0a..bb7db9fd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ "docstring_parser>=0.7", "psygnal>=0.5.0", "qtpy>=1.7.0", - "superqt>=0.5.0", + "superqt[iconify]>=0.6.1", "typing_extensions", ] @@ -48,7 +48,7 @@ min-req = [ "docstring_parser==0.7", "psygnal==0.5.0", "qtpy==1.7.0", - "superqt==0.5.0", + "superqt==0.6.1", "typing_extensions", ] diff --git a/src/magicgui/backends/_ipynb/widgets.py b/src/magicgui/backends/_ipynb/widgets.py index 93af80acf..58f9971db 100644 --- a/src/magicgui/backends/_ipynb/widgets.py +++ b/src/magicgui/backends/_ipynb/widgets.py @@ -1,3 +1,5 @@ +# from __future__ import annotations # NO + from typing import Any, Callable, Iterable, Optional, Tuple, Type, Union try: @@ -9,6 +11,7 @@ "Please run `pip install ipywidgets`" ) from e + from magicgui.widgets import protocols from magicgui.widgets.bases import Widget @@ -260,11 +263,32 @@ def _mgui_get_text(self) -> str: return self._ipywidget.description +class _IPySupportsIcon(protocols.SupportsIcon): + """Widget that can show an icon.""" + + _ipywidget: ipywdg.Button + + def _mgui_set_icon(self, value: Optional[str], color: Optional[str]) -> None: + """Set icon.""" + # only ipywdg.Button actually supports icons. + # but our button protocol allows it for all buttons subclasses + # so we need this method in the concrete subclasses, but we + # can't actually set the icon for anything but ipywdg.Button + if hasattr(self._ipywidget, "icon"): + # by splitting on ":" we allow for "prefix:icon-name" syntax + # which works for iconify icons served by qt, while still + # allowing for bare "icon-name" syntax which works for ipywidgets. + # note however... only fa4/5 icons will work for ipywidgets. + value = value or "" + self._ipywidget.icon = value.replace("fa-", "").split(":", 1)[-1] + self._ipywidget.style.text_color = color + + class _IPyCategoricalWidget(_IPyValueWidget, _IPySupportsChoices): pass -class _IPyButtonWidget(_IPyValueWidget, _IPySupportsText): +class _IPyButtonWidget(_IPyValueWidget, _IPySupportsText, _IPySupportsIcon): pass diff --git a/src/magicgui/backends/_qtpy/widgets.py b/src/magicgui/backends/_qtpy/widgets.py index 71ce796b6..cf47053df 100644 --- a/src/magicgui/backends/_qtpy/widgets.py +++ b/src/magicgui/backends/_qtpy/widgets.py @@ -17,8 +17,10 @@ from qtpy.QtGui import ( QFont, QFontMetrics, + QIcon, QImage, QKeyEvent, + QPalette, QPixmap, QResizeEvent, QTextDocument, @@ -48,10 +50,13 @@ def _signals_blocked(obj: QtW.QWidget) -> Iterator[None]: class EventFilter(QObject): parentChanged = Signal() valueChanged = Signal(object) + paletteChanged = Signal() def eventFilter(self, obj: QObject, event: QEvent) -> bool: if event.type() == QEvent.Type.ParentChange: self.parentChanged.emit() + if event.type() == QEvent.Type.PaletteChange: + self.paletteChanged.emit() return False @@ -419,11 +424,15 @@ def _update_precision(self, **kwargs: Any) -> None: # BUTTONS -class QBaseButtonWidget(QBaseValueWidget, protocols.SupportsText): +class QBaseButtonWidget( + QBaseValueWidget, protocols.SupportsText, protocols.SupportsIcon +): _qwidget: QtW.QCheckBox | QtW.QPushButton | QtW.QRadioButton | QtW.QToolButton - def __init__(self, qwidg: type[QtW.QWidget], **kwargs: Any) -> None: + def __init__(self, qwidg: type[QtW.QAbstractButton], **kwargs: Any) -> None: super().__init__(qwidg, "isChecked", "setChecked", "toggled", **kwargs) + self._event_filter.paletteChanged.connect(self._update_icon) + self._icon: tuple[str | None, str | None] | None = None def _mgui_set_text(self, value: str) -> None: """Set text.""" @@ -433,12 +442,48 @@ def _mgui_get_text(self) -> str: """Get text.""" return self._qwidget.text() + def _update_icon(self) -> None: + # Called when palette changes or icon is set + if self._icon: + qicon = _get_qicon(*self._icon, palette=self._qwidget.palette()) + if qicon is None: + self._icon = None # an error occurred don't try again + self._qwidget.setIcon(QIcon()) + else: + self._qwidget.setIcon(qicon) + + def _mgui_set_icon(self, value: str | None, color: str | None) -> None: + self._icon = (value, color) + self._update_icon() + + +def _get_qicon(key: str | None, color: str | None, palette: QPalette) -> QIcon | None: + """Return a QIcon from iconify, or None if it fails.""" + if not key: + return QIcon() + + if not color or color == "auto": + # use foreground color + color = palette.color(QPalette.ColorRole.WindowText).name() + # don't use full black or white + color = {"#000000": "#333333", "#ffffff": "#cccccc"}.get(color, color) + + if ":" not in key: + # for parity with the other backends, assume fontawesome + # if no prefix is given. + key = f"fa:{key}" + + try: + return superqt.QIconifyIcon(key, color=color) + except (OSError, ValueError) as e: + warnings.warn(f"Could not set iconify icon: {e}", stacklevel=2) + return None + class PushButton(QBaseButtonWidget): def __init__(self, **kwargs: Any) -> None: - QBaseValueWidget.__init__( - self, QtW.QPushButton, "isChecked", "setChecked", "clicked", **kwargs - ) + super().__init__(QtW.QPushButton, **kwargs) + self._onchange_name = "clicked" # make enter/return "click" the button when focused. self._qwidget.setAutoDefault(True) diff --git a/src/magicgui/widgets/bases/_button_widget.py b/src/magicgui/widgets/bases/_button_widget.py index 7deba402b..faed7d484 100644 --- a/src/magicgui/widgets/bases/_button_widget.py +++ b/src/magicgui/widgets/bases/_button_widget.py @@ -52,6 +52,8 @@ def __init__( value: bool | _Undefined = Undefined, *, text: str | None = None, + icon: str | None = None, + icon_color: str | None = None, bind: bool | Callable[[ValueWidget], bool] | _Undefined = Undefined, nullable: bool = False, **base_widget_kwargs: Unpack[WidgetKwargs], @@ -72,6 +74,8 @@ def __init__( value=value, bind=bind, nullable=nullable, **base_widget_kwargs ) self.text = (text or self.name).replace("_", " ") + if icon: + self.set_icon(icon, icon_color) @property def options(self) -> dict: @@ -93,3 +97,6 @@ def text(self, value: str) -> None: def clicked(self) -> SignalInstance: """Alias for changed event.""" return self.changed + + def set_icon(self, value: str | None, color: str | None = None) -> None: + self._widget._mgui_set_icon(value, color) diff --git a/src/magicgui/widgets/protocols.py b/src/magicgui/widgets/protocols.py index 316528173..c730ea4d2 100644 --- a/src/magicgui/widgets/protocols.py +++ b/src/magicgui/widgets/protocols.py @@ -438,7 +438,21 @@ def _mgui_get_text(self) -> str: @runtime_checkable -class ButtonWidgetProtocol(ValueWidgetProtocol, SupportsText, Protocol): +class SupportsIcon(Protocol): + """Widget that can be reoriented.""" + + @abstractmethod + def _mgui_set_icon(self, value: str | None, color: str | None) -> None: + """Set icon. + + Value is an "prefix:name" from iconify: https://icon-sets.iconify.design + Color is any valid CSS color string. + Set value to `None` or an empty string to remove icon. + """ + + +@runtime_checkable +class ButtonWidgetProtocol(ValueWidgetProtocol, SupportsText, SupportsIcon, Protocol): """The "value" in a ButtonWidget is the current (checked) state.""" diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 60abd1fc2..b12fd93a3 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -809,6 +809,17 @@ def test_pushbutton_click_signal(): mock2.assert_called_once() +def test_pushbutton_icon(backend: str): + use_app(backend) + btn = widgets.PushButton(icon="mdi:folder") + btn.set_icon("play", "red") + btn.set_icon(None) + + if backend == "qt": + with pytest.warns(UserWarning, match="Could not set iconify icon"): + btn.set_icon("bad:key") + + def test_list_edit(): """Test ListEdit.""" from typing import List