Skip to content

Commit 2fa477d

Browse files
authored
feat: add icons on buttons (#598)
1 parent 05c9292 commit 2fa477d

File tree

6 files changed

+110
-9
lines changed

6 files changed

+110
-9
lines changed

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ dependencies = [
3737
"docstring_parser>=0.7",
3838
"psygnal>=0.5.0",
3939
"qtpy>=1.7.0",
40-
"superqt>=0.5.0",
40+
"superqt[iconify]>=0.6.1",
4141
"typing_extensions",
4242
]
4343

@@ -48,7 +48,7 @@ min-req = [
4848
"docstring_parser==0.7",
4949
"psygnal==0.5.0",
5050
"qtpy==1.7.0",
51-
"superqt==0.5.0",
51+
"superqt==0.6.1",
5252
"typing_extensions",
5353
]
5454

src/magicgui/backends/_ipynb/widgets.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# from __future__ import annotations # NO
2+
13
from typing import Any, Callable, Iterable, Optional, Tuple, Type, Union
24

35
try:
@@ -9,6 +11,7 @@
911
"Please run `pip install ipywidgets`"
1012
) from e
1113

14+
1215
from magicgui.widgets import protocols
1316
from magicgui.widgets.bases import Widget
1417

@@ -260,11 +263,32 @@ def _mgui_get_text(self) -> str:
260263
return self._ipywidget.description
261264

262265

266+
class _IPySupportsIcon(protocols.SupportsIcon):
267+
"""Widget that can show an icon."""
268+
269+
_ipywidget: ipywdg.Button
270+
271+
def _mgui_set_icon(self, value: Optional[str], color: Optional[str]) -> None:
272+
"""Set icon."""
273+
# only ipywdg.Button actually supports icons.
274+
# but our button protocol allows it for all buttons subclasses
275+
# so we need this method in the concrete subclasses, but we
276+
# can't actually set the icon for anything but ipywdg.Button
277+
if hasattr(self._ipywidget, "icon"):
278+
# by splitting on ":" we allow for "prefix:icon-name" syntax
279+
# which works for iconify icons served by qt, while still
280+
# allowing for bare "icon-name" syntax which works for ipywidgets.
281+
# note however... only fa4/5 icons will work for ipywidgets.
282+
value = value or ""
283+
self._ipywidget.icon = value.replace("fa-", "").split(":", 1)[-1]
284+
self._ipywidget.style.text_color = color
285+
286+
263287
class _IPyCategoricalWidget(_IPyValueWidget, _IPySupportsChoices):
264288
pass
265289

266290

267-
class _IPyButtonWidget(_IPyValueWidget, _IPySupportsText):
291+
class _IPyButtonWidget(_IPyValueWidget, _IPySupportsText, _IPySupportsIcon):
268292
pass
269293

270294

src/magicgui/backends/_qtpy/widgets.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
from qtpy.QtGui import (
1818
QFont,
1919
QFontMetrics,
20+
QIcon,
2021
QImage,
2122
QKeyEvent,
23+
QPalette,
2224
QPixmap,
2325
QResizeEvent,
2426
QTextDocument,
@@ -48,10 +50,13 @@ def _signals_blocked(obj: QtW.QWidget) -> Iterator[None]:
4850
class EventFilter(QObject):
4951
parentChanged = Signal()
5052
valueChanged = Signal(object)
53+
paletteChanged = Signal()
5154

5255
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
5356
if event.type() == QEvent.Type.ParentChange:
5457
self.parentChanged.emit()
58+
if event.type() == QEvent.Type.PaletteChange:
59+
self.paletteChanged.emit()
5560
return False
5661

5762

@@ -419,11 +424,15 @@ def _update_precision(self, **kwargs: Any) -> None:
419424
# BUTTONS
420425

421426

422-
class QBaseButtonWidget(QBaseValueWidget, protocols.SupportsText):
427+
class QBaseButtonWidget(
428+
QBaseValueWidget, protocols.SupportsText, protocols.SupportsIcon
429+
):
423430
_qwidget: QtW.QCheckBox | QtW.QPushButton | QtW.QRadioButton | QtW.QToolButton
424431

425-
def __init__(self, qwidg: type[QtW.QWidget], **kwargs: Any) -> None:
432+
def __init__(self, qwidg: type[QtW.QAbstractButton], **kwargs: Any) -> None:
426433
super().__init__(qwidg, "isChecked", "setChecked", "toggled", **kwargs)
434+
self._event_filter.paletteChanged.connect(self._update_icon)
435+
self._icon: tuple[str | None, str | None] | None = None
427436

428437
def _mgui_set_text(self, value: str) -> None:
429438
"""Set text."""
@@ -433,12 +442,48 @@ def _mgui_get_text(self) -> str:
433442
"""Get text."""
434443
return self._qwidget.text()
435444

445+
def _update_icon(self) -> None:
446+
# Called when palette changes or icon is set
447+
if self._icon:
448+
qicon = _get_qicon(*self._icon, palette=self._qwidget.palette())
449+
if qicon is None:
450+
self._icon = None # an error occurred don't try again
451+
self._qwidget.setIcon(QIcon())
452+
else:
453+
self._qwidget.setIcon(qicon)
454+
455+
def _mgui_set_icon(self, value: str | None, color: str | None) -> None:
456+
self._icon = (value, color)
457+
self._update_icon()
458+
459+
460+
def _get_qicon(key: str | None, color: str | None, palette: QPalette) -> QIcon | None:
461+
"""Return a QIcon from iconify, or None if it fails."""
462+
if not key:
463+
return QIcon()
464+
465+
if not color or color == "auto":
466+
# use foreground color
467+
color = palette.color(QPalette.ColorRole.WindowText).name()
468+
# don't use full black or white
469+
color = {"#000000": "#333333", "#ffffff": "#cccccc"}.get(color, color)
470+
471+
if ":" not in key:
472+
# for parity with the other backends, assume fontawesome
473+
# if no prefix is given.
474+
key = f"fa:{key}"
475+
476+
try:
477+
return superqt.QIconifyIcon(key, color=color)
478+
except (OSError, ValueError) as e:
479+
warnings.warn(f"Could not set iconify icon: {e}", stacklevel=2)
480+
return None
481+
436482

437483
class PushButton(QBaseButtonWidget):
438484
def __init__(self, **kwargs: Any) -> None:
439-
QBaseValueWidget.__init__(
440-
self, QtW.QPushButton, "isChecked", "setChecked", "clicked", **kwargs
441-
)
485+
super().__init__(QtW.QPushButton, **kwargs)
486+
self._onchange_name = "clicked"
442487
# make enter/return "click" the button when focused.
443488
self._qwidget.setAutoDefault(True)
444489

src/magicgui/widgets/bases/_button_widget.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ def __init__(
5252
value: bool | _Undefined = Undefined,
5353
*,
5454
text: str | None = None,
55+
icon: str | None = None,
56+
icon_color: str | None = None,
5557
bind: bool | Callable[[ValueWidget], bool] | _Undefined = Undefined,
5658
nullable: bool = False,
5759
**base_widget_kwargs: Unpack[WidgetKwargs],
@@ -72,6 +74,8 @@ def __init__(
7274
value=value, bind=bind, nullable=nullable, **base_widget_kwargs
7375
)
7476
self.text = (text or self.name).replace("_", " ")
77+
if icon:
78+
self.set_icon(icon, icon_color)
7579

7680
@property
7781
def options(self) -> dict:
@@ -93,3 +97,6 @@ def text(self, value: str) -> None:
9397
def clicked(self) -> SignalInstance:
9498
"""Alias for changed event."""
9599
return self.changed
100+
101+
def set_icon(self, value: str | None, color: str | None = None) -> None:
102+
self._widget._mgui_set_icon(value, color)

src/magicgui/widgets/protocols.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,21 @@ def _mgui_get_text(self) -> str:
438438

439439

440440
@runtime_checkable
441-
class ButtonWidgetProtocol(ValueWidgetProtocol, SupportsText, Protocol):
441+
class SupportsIcon(Protocol):
442+
"""Widget that can be reoriented."""
443+
444+
@abstractmethod
445+
def _mgui_set_icon(self, value: str | None, color: str | None) -> None:
446+
"""Set icon.
447+
448+
Value is an "prefix:name" from iconify: https://icon-sets.iconify.design
449+
Color is any valid CSS color string.
450+
Set value to `None` or an empty string to remove icon.
451+
"""
452+
453+
454+
@runtime_checkable
455+
class ButtonWidgetProtocol(ValueWidgetProtocol, SupportsText, SupportsIcon, Protocol):
442456
"""The "value" in a ButtonWidget is the current (checked) state."""
443457

444458

tests/test_widgets.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,17 @@ def test_pushbutton_click_signal():
809809
mock2.assert_called_once()
810810

811811

812+
def test_pushbutton_icon(backend: str):
813+
use_app(backend)
814+
btn = widgets.PushButton(icon="mdi:folder")
815+
btn.set_icon("play", "red")
816+
btn.set_icon(None)
817+
818+
if backend == "qt":
819+
with pytest.warns(UserWarning, match="Could not set iconify icon"):
820+
btn.set_icon("bad:key")
821+
822+
812823
def test_list_edit():
813824
"""Test ListEdit."""
814825
from typing import List

0 commit comments

Comments
 (0)