Skip to content

Commit

Permalink
feat: add icons on buttons (#598)
Browse files Browse the repository at this point in the history
  • Loading branch information
tlambert03 authored Oct 11, 2023
1 parent 05c9292 commit 2fa477d
Show file tree
Hide file tree
Showing 6 changed files with 110 additions and 9 deletions.
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand All @@ -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",
]

Expand Down
26 changes: 25 additions & 1 deletion src/magicgui/backends/_ipynb/widgets.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# from __future__ import annotations # NO

from typing import Any, Callable, Iterable, Optional, Tuple, Type, Union

try:
Expand All @@ -9,6 +11,7 @@
"Please run `pip install ipywidgets`"
) from e


from magicgui.widgets import protocols
from magicgui.widgets.bases import Widget

Expand Down Expand Up @@ -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


Expand Down
55 changes: 50 additions & 5 deletions src/magicgui/backends/_qtpy/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
from qtpy.QtGui import (
QFont,
QFontMetrics,
QIcon,
QImage,
QKeyEvent,
QPalette,
QPixmap,
QResizeEvent,
QTextDocument,
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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."""
Expand All @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions src/magicgui/widgets/bases/_button_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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:
Expand All @@ -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)
16 changes: 15 additions & 1 deletion src/magicgui/widgets/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""


Expand Down
11 changes: 11 additions & 0 deletions tests/test_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 2fa477d

Please sign in to comment.