Skip to content

Commit

Permalink
feat: add toolbars [wip] (#597)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
tlambert03 authored Oct 22, 2023
1 parent b28c499 commit 16b3658
Show file tree
Hide file tree
Showing 10 changed files with 240 additions and 2 deletions.
2 changes: 2 additions & 0 deletions src/magicgui/backends/_ipynb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
SpinBox,
TextEdit,
TimeEdit,
ToolBar,
get_text_width,
)

Expand Down Expand Up @@ -46,6 +47,7 @@
"Slider",
"SpinBox",
"TextEdit",
"ToolBar",
"get_text_width",
"show_file_dialog",
]
61 changes: 61 additions & 0 deletions src/magicgui/backends/_ipynb/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/magicgui/backends/_qtpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
Table,
TextEdit,
TimeEdit,
ToolBar,
get_text_width,
show_file_dialog,
)
Expand Down Expand Up @@ -64,4 +65,5 @@
"Table",
"TextEdit",
"TimeEdit",
"ToolBar",
]
59 changes: 58 additions & 1 deletion src/magicgui/backends/_qtpy/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/magicgui/widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
SpinBox,
TextEdit,
TimeEdit,
ToolBar,
TupleEdit,
)
from ._dialogs import request_values, show_file_dialog
Expand Down Expand Up @@ -107,6 +108,7 @@
"Table",
"TextEdit",
"TimeEdit",
"ToolBar",
"TupleEdit",
"Widget",
"show_file_dialog",
Expand Down
6 changes: 6 additions & 0 deletions src/magicgui/widgets/_concrete.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
MultiValuedSliderWidget,
RangedWidget,
SliderWidget,
ToolBarWidget,
TransformedRangedWidget,
ValueWidget,
Widget,
Expand Down Expand Up @@ -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."""

Expand Down
4 changes: 3 additions & 1 deletion src/magicgui/widgets/bases/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,22 @@ 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

__all__ = [
"ButtonWidget",
"CategoricalWidget",
"ContainerWidget",
"create_widget",
"DialogWidget",
"MainWindowWidget",
"MultiValuedSliderWidget",
"RangedWidget",
"SliderWidget",
"ToolBarWidget",
"TransformedRangedWidget",
"ValueWidget",
"Widget",
"create_widget",
]
60 changes: 60 additions & 0 deletions src/magicgui/widgets/bases/_toolbar.py
Original file line number Diff line number Diff line change
@@ -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()
35 changes: 35 additions & 0 deletions src/magicgui/widgets/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

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

0 comments on commit 16b3658

Please sign in to comment.