From 16b3658e98f9a5fe8dd5069039ad6fa332569ede Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 22 Oct 2023 13:39:07 -0400 Subject: [PATCH] feat: add toolbars [wip] (#597) * wip * update ipywidgets implementation * fix hints * add test * feat: support button icons * adding iconbtn * match color to palette in qt * update ipywidgets * bump superqt * add pytest-pretty * test: add tests * fix icon * extract logic, fix 3.8 * update color * change with palette * unions --- src/magicgui/backends/_ipynb/__init__.py | 2 + src/magicgui/backends/_ipynb/widgets.py | 61 ++++++++++++++++++++++++ src/magicgui/backends/_qtpy/__init__.py | 2 + src/magicgui/backends/_qtpy/widgets.py | 59 ++++++++++++++++++++++- src/magicgui/widgets/__init__.py | 2 + src/magicgui/widgets/_concrete.py | 6 +++ src/magicgui/widgets/bases/__init__.py | 4 +- src/magicgui/widgets/bases/_toolbar.py | 60 +++++++++++++++++++++++ src/magicgui/widgets/protocols.py | 35 ++++++++++++++ tests/test_widgets.py | 11 +++++ 10 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 src/magicgui/widgets/bases/_toolbar.py diff --git a/src/magicgui/backends/_ipynb/__init__.py b/src/magicgui/backends/_ipynb/__init__.py index 1c82d18f2..b9f98e29a 100644 --- a/src/magicgui/backends/_ipynb/__init__.py +++ b/src/magicgui/backends/_ipynb/__init__.py @@ -19,6 +19,7 @@ SpinBox, TextEdit, TimeEdit, + ToolBar, get_text_width, ) @@ -46,6 +47,7 @@ "Slider", "SpinBox", "TextEdit", + "ToolBar", "get_text_width", "show_file_dialog", ] diff --git a/src/magicgui/backends/_ipynb/widgets.py b/src/magicgui/backends/_ipynb/widgets.py index 58f9971db..84928691d 100644 --- a/src/magicgui/backends/_ipynb/widgets.py +++ b/src/magicgui/backends/_ipynb/widgets.py @@ -350,6 +350,67 @@ class TimeEdit(_IPyValueWidget): _ipywidget: ipywdg.TimePicker +class ToolBar(_IPyWidget): + _ipywidget: ipywidgets.HBox + + def __init__(self, **kwargs): + super().__init__(ipywidgets.HBox, **kwargs) + self._icon_sz: Optional[Tuple[int, int]] = None + + def _mgui_add_button(self, text: str, icon: str, callback: Callable) -> None: + """Add an action to the toolbar.""" + btn = ipywdg.Button( + description=text, icon=icon, layout={"width": "auto", "height": "auto"} + ) + if callback: + btn.on_click(lambda e: callback()) + self._add_ipywidget(btn) + + def _add_ipywidget(self, widget: "ipywidgets.Widget") -> None: + children = list(self._ipywidget.children) + children.append(widget) + self._ipywidget.children = children + + def _mgui_add_separator(self) -> None: + """Add a separator line to the toolbar.""" + # Define the vertical separator + sep = ipywdg.Box( + layout=ipywdg.Layout(border_left="1px dotted gray", margin="1px 4px") + ) + self._add_ipywidget(sep) + + def _mgui_add_spacer(self) -> None: + """Add a spacer to the toolbar.""" + self._add_ipywidget(ipywdg.Box(layout=ipywdg.Layout(flex="1"))) + + def _mgui_add_widget(self, widget: "Widget") -> None: + """Add a widget to the toolbar.""" + self._add_ipywidget(widget.native) + + def _mgui_get_icon_size(self) -> Optional[Tuple[int, int]]: + """Return the icon size of the toolbar.""" + return self._icon_sz + + def _mgui_set_icon_size(self, size: Union[int, Tuple[int, int], None]) -> None: + """Set the icon size of the toolbar.""" + if isinstance(size, int): + size = (size, size) + elif size is None: + size = (0, 0) + elif not isinstance(size, tuple): + raise ValueError("icon size must be an int or tuple of ints") + sz = max(size) + self._icon_sz = (sz, sz) + for child in self._ipywidget.children: + if hasattr(child, "style"): + child.style.font_size = f"{sz}px" if sz else None + child.layout.min_height = f"{sz*2}px" if sz else None + + def _mgui_clear(self) -> None: + """Clear the toolbar.""" + self._ipywidget.children = () + + class PushButton(_IPyButtonWidget): _ipywidget: ipywdg.Button diff --git a/src/magicgui/backends/_qtpy/__init__.py b/src/magicgui/backends/_qtpy/__init__.py index 046859544..a42b100c3 100644 --- a/src/magicgui/backends/_qtpy/__init__.py +++ b/src/magicgui/backends/_qtpy/__init__.py @@ -28,6 +28,7 @@ Table, TextEdit, TimeEdit, + ToolBar, get_text_width, show_file_dialog, ) @@ -64,4 +65,5 @@ "Table", "TextEdit", "TimeEdit", + "ToolBar", ] diff --git a/src/magicgui/backends/_qtpy/widgets.py b/src/magicgui/backends/_qtpy/widgets.py index cf47053df..a94bd3665 100644 --- a/src/magicgui/backends/_qtpy/widgets.py +++ b/src/magicgui/backends/_qtpy/widgets.py @@ -13,7 +13,7 @@ import qtpy import superqt from qtpy import QtWidgets as QtW -from qtpy.QtCore import QEvent, QObject, Qt, Signal +from qtpy.QtCore import QEvent, QObject, QSize, Qt, Signal from qtpy.QtGui import ( QFont, QFontMetrics, @@ -1217,6 +1217,63 @@ def _mgui_get_value(self): return self._qwidget.time().toPyTime() +class ToolBar(QBaseWidget): + _qwidget: QtW.QToolBar + + def __init__(self, **kwargs: Any) -> None: + super().__init__(QtW.QToolBar, **kwargs) + self._qwidget.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) + self._event_filter.paletteChanged.connect(self._on_palette_change) + + def _on_palette_change(self): + for action in self._qwidget.actions(): + if icon := action.data(): + if qicon := _get_qicon(icon, None, palette=self._qwidget.palette()): + action.setIcon(qicon) + + def _mgui_add_button(self, text: str, icon: str, callback: Callable) -> None: + """Add an action to the toolbar.""" + act = self._qwidget.addAction(text, callback) + if qicon := _get_qicon(icon, None, palette=self._qwidget.palette()): + act.setIcon(qicon) + act.setData(icon) + + def _mgui_add_separator(self) -> None: + """Add a separator line to the toolbar.""" + self._qwidget.addSeparator() + + def _mgui_add_spacer(self) -> None: + """Add a spacer to the toolbar.""" + empty = QtW.QWidget() + empty.setSizePolicy( + QtW.QSizePolicy.Policy.Expanding, QtW.QSizePolicy.Policy.Preferred + ) + self._qwidget.addWidget(empty) + + def _mgui_add_widget(self, widget: Widget) -> None: + """Add a widget to the toolbar.""" + self._qwidget.addWidget(widget.native) + + def _mgui_get_icon_size(self) -> tuple[int, int] | None: + """Return the icon size of the toolbar.""" + sz = self._qwidget.iconSize() + return None if sz.isNull() else (sz.width(), sz.height()) + + def _mgui_set_icon_size(self, size: int | tuple[int, int] | None) -> None: + """Set the icon size of the toolbar.""" + if isinstance(size, int): + _size = QSize(size, size) + elif isinstance(size, tuple): + _size = QSize(size[0], size[1]) + else: + _size = QSize() + self._qwidget.setIconSize(_size) + + def _mgui_clear(self) -> None: + """Clear the toolbar.""" + self._qwidget.clear() + + class Dialog(QBaseWidget, protocols.ContainerProtocol): def __init__( self, layout="vertical", scrollable: bool = False, **kwargs: Any diff --git a/src/magicgui/widgets/__init__.py b/src/magicgui/widgets/__init__.py index ffc556ee3..2478afb0d 100644 --- a/src/magicgui/widgets/__init__.py +++ b/src/magicgui/widgets/__init__.py @@ -43,6 +43,7 @@ SpinBox, TextEdit, TimeEdit, + ToolBar, TupleEdit, ) from ._dialogs import request_values, show_file_dialog @@ -107,6 +108,7 @@ "Table", "TextEdit", "TimeEdit", + "ToolBar", "TupleEdit", "Widget", "show_file_dialog", diff --git a/src/magicgui/widgets/_concrete.py b/src/magicgui/widgets/_concrete.py index f7ea08ba3..e2fc1d938 100644 --- a/src/magicgui/widgets/_concrete.py +++ b/src/magicgui/widgets/_concrete.py @@ -46,6 +46,7 @@ MultiValuedSliderWidget, RangedWidget, SliderWidget, + ToolBarWidget, TransformedRangedWidget, ValueWidget, Widget, @@ -969,6 +970,11 @@ def value(self, vals: Sequence) -> None: self.changed.emit(self.value) +@backend_widget +class ToolBar(ToolBarWidget): + """Toolbar that contains a set of controls.""" + + class _LabeledWidget(Container): """Simple container that wraps a widget and provides a label.""" diff --git a/src/magicgui/widgets/bases/__init__.py b/src/magicgui/widgets/bases/__init__.py index dcda1c7de..176a12e9f 100644 --- a/src/magicgui/widgets/bases/__init__.py +++ b/src/magicgui/widgets/bases/__init__.py @@ -48,6 +48,7 @@ def __init__( from ._create_widget import create_widget from ._ranged_widget import RangedWidget, TransformedRangedWidget from ._slider_widget import MultiValuedSliderWidget, SliderWidget +from ._toolbar import ToolBarWidget from ._value_widget import ValueWidget from ._widget import Widget @@ -55,13 +56,14 @@ def __init__( "ButtonWidget", "CategoricalWidget", "ContainerWidget", + "create_widget", "DialogWidget", "MainWindowWidget", "MultiValuedSliderWidget", "RangedWidget", "SliderWidget", + "ToolBarWidget", "TransformedRangedWidget", "ValueWidget", "Widget", - "create_widget", ] diff --git a/src/magicgui/widgets/bases/_toolbar.py b/src/magicgui/widgets/bases/_toolbar.py new file mode 100644 index 000000000..c8d87766e --- /dev/null +++ b/src/magicgui/widgets/bases/_toolbar.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, Tuple, TypeVar, Union + +from ._widget import Widget + +if TYPE_CHECKING: + from magicgui.widgets import protocols + +T = TypeVar("T", int, float, Tuple[Union[int, float], ...]) +DEFAULT_MIN = 0.0 +DEFAULT_MAX = 1000.0 + + +class ToolBarWidget(Widget): + """Widget with a value, Wraps ValueWidgetProtocol. + + Parameters + ---------- + **base_widget_kwargs : Any + All additional keyword arguments are passed to the base + [`magicgui.widgets.Widget`][magicgui.widgets.Widget] constructor. + """ + + _widget: protocols.ToolBarProtocol + + def __init__(self, **base_widget_kwargs: Any) -> None: + super().__init__(**base_widget_kwargs) + + def add_button( + self, text: str = "", icon: str = "", callback: Callable | None = None + ) -> None: + """Add an action to the toolbar.""" + self._widget._mgui_add_button(text, icon, callback) + + def add_separator(self) -> None: + """Add a separator line to the toolbar.""" + self._widget._mgui_add_separator() + + def add_spacer(self) -> None: + """Add a spacer to the toolbar.""" + self._widget._mgui_add_spacer() + + def add_widget(self, widget: Widget) -> None: + """Add a widget to the toolbar.""" + self._widget._mgui_add_widget(widget) + + @property + def icon_size(self) -> tuple[int, int] | None: + """Return the icon size of the toolbar.""" + return self._widget._mgui_get_icon_size() + + @icon_size.setter + def icon_size(self, size: int | tuple[int, int] | None) -> None: + """Set the icon size of the toolbar.""" + self._widget._mgui_set_icon_size(size) + + def clear(self) -> None: + """Clear the toolbar.""" + self._widget._mgui_clear() diff --git a/src/magicgui/widgets/protocols.py b/src/magicgui/widgets/protocols.py index c730ea4d2..952dc8fdd 100644 --- a/src/magicgui/widgets/protocols.py +++ b/src/magicgui/widgets/protocols.py @@ -515,6 +515,41 @@ def _mgui_set_margins(self, margins: tuple[int, int, int, int]) -> None: raise NotImplementedError() +@runtime_checkable +class ToolBarProtocol(WidgetProtocol, Protocol): + """Toolbar that contains a set of controls.""" + + @abstractmethod + def _mgui_add_button( + self, text: str, icon: str, callback: Callable | None = None + ) -> None: + """Add a button to the toolbar.""" + + @abstractmethod + def _mgui_add_separator(self) -> None: + """Add a separator line to the toolbar.""" + + @abstractmethod + def _mgui_add_spacer(self) -> None: + """Add a spacer to the toolbar.""" + + @abstractmethod + def _mgui_add_widget(self, widget: Widget) -> None: + """Add a widget to the toolbar.""" + + @abstractmethod + def _mgui_get_icon_size(self) -> tuple[int, int] | None: + """Return the icon size of the toolbar.""" + + @abstractmethod + def _mgui_set_icon_size(self, size: int | tuple[int, int] | None) -> None: + """Set the icon size of the toolbar.""" + + @abstractmethod + def _mgui_clear(self) -> None: + """Clear the toolbar.""" + + class DialogProtocol(ContainerProtocol, Protocol): """Protocol for modal (blocking) containers.""" diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 3d94e18fe..99df22cf1 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -1067,3 +1067,14 @@ def test_float_slider_readout(): assert sld._widget._readout_widget.value() == 4 assert sld._widget._readout_widget.minimum() == 0.5 assert sld._widget._readout_widget.maximum() == 10.5 + + +def test_toolbar(): + tb = widgets.ToolBar() + tb.add_button("test", callback=lambda: None) + tb.add_separator() + tb.add_spacer() + tb.add_button("test2", callback=lambda: None) + tb.icon_size = 26 + assert tb.icon_size == (26, 26) + tb.clear()