Skip to content

Commit 16b3658

Browse files
authored
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
1 parent b28c499 commit 16b3658

File tree

10 files changed

+240
-2
lines changed

10 files changed

+240
-2
lines changed

src/magicgui/backends/_ipynb/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
SpinBox,
2020
TextEdit,
2121
TimeEdit,
22+
ToolBar,
2223
get_text_width,
2324
)
2425

@@ -46,6 +47,7 @@
4647
"Slider",
4748
"SpinBox",
4849
"TextEdit",
50+
"ToolBar",
4951
"get_text_width",
5052
"show_file_dialog",
5153
]

src/magicgui/backends/_ipynb/widgets.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,67 @@ class TimeEdit(_IPyValueWidget):
350350
_ipywidget: ipywdg.TimePicker
351351

352352

353+
class ToolBar(_IPyWidget):
354+
_ipywidget: ipywidgets.HBox
355+
356+
def __init__(self, **kwargs):
357+
super().__init__(ipywidgets.HBox, **kwargs)
358+
self._icon_sz: Optional[Tuple[int, int]] = None
359+
360+
def _mgui_add_button(self, text: str, icon: str, callback: Callable) -> None:
361+
"""Add an action to the toolbar."""
362+
btn = ipywdg.Button(
363+
description=text, icon=icon, layout={"width": "auto", "height": "auto"}
364+
)
365+
if callback:
366+
btn.on_click(lambda e: callback())
367+
self._add_ipywidget(btn)
368+
369+
def _add_ipywidget(self, widget: "ipywidgets.Widget") -> None:
370+
children = list(self._ipywidget.children)
371+
children.append(widget)
372+
self._ipywidget.children = children
373+
374+
def _mgui_add_separator(self) -> None:
375+
"""Add a separator line to the toolbar."""
376+
# Define the vertical separator
377+
sep = ipywdg.Box(
378+
layout=ipywdg.Layout(border_left="1px dotted gray", margin="1px 4px")
379+
)
380+
self._add_ipywidget(sep)
381+
382+
def _mgui_add_spacer(self) -> None:
383+
"""Add a spacer to the toolbar."""
384+
self._add_ipywidget(ipywdg.Box(layout=ipywdg.Layout(flex="1")))
385+
386+
def _mgui_add_widget(self, widget: "Widget") -> None:
387+
"""Add a widget to the toolbar."""
388+
self._add_ipywidget(widget.native)
389+
390+
def _mgui_get_icon_size(self) -> Optional[Tuple[int, int]]:
391+
"""Return the icon size of the toolbar."""
392+
return self._icon_sz
393+
394+
def _mgui_set_icon_size(self, size: Union[int, Tuple[int, int], None]) -> None:
395+
"""Set the icon size of the toolbar."""
396+
if isinstance(size, int):
397+
size = (size, size)
398+
elif size is None:
399+
size = (0, 0)
400+
elif not isinstance(size, tuple):
401+
raise ValueError("icon size must be an int or tuple of ints")
402+
sz = max(size)
403+
self._icon_sz = (sz, sz)
404+
for child in self._ipywidget.children:
405+
if hasattr(child, "style"):
406+
child.style.font_size = f"{sz}px" if sz else None
407+
child.layout.min_height = f"{sz*2}px" if sz else None
408+
409+
def _mgui_clear(self) -> None:
410+
"""Clear the toolbar."""
411+
self._ipywidget.children = ()
412+
413+
353414
class PushButton(_IPyButtonWidget):
354415
_ipywidget: ipywdg.Button
355416

src/magicgui/backends/_qtpy/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
Table,
2929
TextEdit,
3030
TimeEdit,
31+
ToolBar,
3132
get_text_width,
3233
show_file_dialog,
3334
)
@@ -64,4 +65,5 @@
6465
"Table",
6566
"TextEdit",
6667
"TimeEdit",
68+
"ToolBar",
6769
]

src/magicgui/backends/_qtpy/widgets.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import qtpy
1414
import superqt
1515
from qtpy import QtWidgets as QtW
16-
from qtpy.QtCore import QEvent, QObject, Qt, Signal
16+
from qtpy.QtCore import QEvent, QObject, QSize, Qt, Signal
1717
from qtpy.QtGui import (
1818
QFont,
1919
QFontMetrics,
@@ -1217,6 +1217,63 @@ def _mgui_get_value(self):
12171217
return self._qwidget.time().toPyTime()
12181218

12191219

1220+
class ToolBar(QBaseWidget):
1221+
_qwidget: QtW.QToolBar
1222+
1223+
def __init__(self, **kwargs: Any) -> None:
1224+
super().__init__(QtW.QToolBar, **kwargs)
1225+
self._qwidget.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
1226+
self._event_filter.paletteChanged.connect(self._on_palette_change)
1227+
1228+
def _on_palette_change(self):
1229+
for action in self._qwidget.actions():
1230+
if icon := action.data():
1231+
if qicon := _get_qicon(icon, None, palette=self._qwidget.palette()):
1232+
action.setIcon(qicon)
1233+
1234+
def _mgui_add_button(self, text: str, icon: str, callback: Callable) -> None:
1235+
"""Add an action to the toolbar."""
1236+
act = self._qwidget.addAction(text, callback)
1237+
if qicon := _get_qicon(icon, None, palette=self._qwidget.palette()):
1238+
act.setIcon(qicon)
1239+
act.setData(icon)
1240+
1241+
def _mgui_add_separator(self) -> None:
1242+
"""Add a separator line to the toolbar."""
1243+
self._qwidget.addSeparator()
1244+
1245+
def _mgui_add_spacer(self) -> None:
1246+
"""Add a spacer to the toolbar."""
1247+
empty = QtW.QWidget()
1248+
empty.setSizePolicy(
1249+
QtW.QSizePolicy.Policy.Expanding, QtW.QSizePolicy.Policy.Preferred
1250+
)
1251+
self._qwidget.addWidget(empty)
1252+
1253+
def _mgui_add_widget(self, widget: Widget) -> None:
1254+
"""Add a widget to the toolbar."""
1255+
self._qwidget.addWidget(widget.native)
1256+
1257+
def _mgui_get_icon_size(self) -> tuple[int, int] | None:
1258+
"""Return the icon size of the toolbar."""
1259+
sz = self._qwidget.iconSize()
1260+
return None if sz.isNull() else (sz.width(), sz.height())
1261+
1262+
def _mgui_set_icon_size(self, size: int | tuple[int, int] | None) -> None:
1263+
"""Set the icon size of the toolbar."""
1264+
if isinstance(size, int):
1265+
_size = QSize(size, size)
1266+
elif isinstance(size, tuple):
1267+
_size = QSize(size[0], size[1])
1268+
else:
1269+
_size = QSize()
1270+
self._qwidget.setIconSize(_size)
1271+
1272+
def _mgui_clear(self) -> None:
1273+
"""Clear the toolbar."""
1274+
self._qwidget.clear()
1275+
1276+
12201277
class Dialog(QBaseWidget, protocols.ContainerProtocol):
12211278
def __init__(
12221279
self, layout="vertical", scrollable: bool = False, **kwargs: Any

src/magicgui/widgets/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
SpinBox,
4444
TextEdit,
4545
TimeEdit,
46+
ToolBar,
4647
TupleEdit,
4748
)
4849
from ._dialogs import request_values, show_file_dialog
@@ -107,6 +108,7 @@
107108
"Table",
108109
"TextEdit",
109110
"TimeEdit",
111+
"ToolBar",
110112
"TupleEdit",
111113
"Widget",
112114
"show_file_dialog",

src/magicgui/widgets/_concrete.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
MultiValuedSliderWidget,
4747
RangedWidget,
4848
SliderWidget,
49+
ToolBarWidget,
4950
TransformedRangedWidget,
5051
ValueWidget,
5152
Widget,
@@ -969,6 +970,11 @@ def value(self, vals: Sequence) -> None:
969970
self.changed.emit(self.value)
970971

971972

973+
@backend_widget
974+
class ToolBar(ToolBarWidget):
975+
"""Toolbar that contains a set of controls."""
976+
977+
972978
class _LabeledWidget(Container):
973979
"""Simple container that wraps a widget and provides a label."""
974980

src/magicgui/widgets/bases/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,20 +48,22 @@ def __init__(
4848
from ._create_widget import create_widget
4949
from ._ranged_widget import RangedWidget, TransformedRangedWidget
5050
from ._slider_widget import MultiValuedSliderWidget, SliderWidget
51+
from ._toolbar import ToolBarWidget
5152
from ._value_widget import ValueWidget
5253
from ._widget import Widget
5354

5455
__all__ = [
5556
"ButtonWidget",
5657
"CategoricalWidget",
5758
"ContainerWidget",
59+
"create_widget",
5860
"DialogWidget",
5961
"MainWindowWidget",
6062
"MultiValuedSliderWidget",
6163
"RangedWidget",
6264
"SliderWidget",
65+
"ToolBarWidget",
6366
"TransformedRangedWidget",
6467
"ValueWidget",
6568
"Widget",
66-
"create_widget",
6769
]
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Any, Callable, Tuple, TypeVar, Union
4+
5+
from ._widget import Widget
6+
7+
if TYPE_CHECKING:
8+
from magicgui.widgets import protocols
9+
10+
T = TypeVar("T", int, float, Tuple[Union[int, float], ...])
11+
DEFAULT_MIN = 0.0
12+
DEFAULT_MAX = 1000.0
13+
14+
15+
class ToolBarWidget(Widget):
16+
"""Widget with a value, Wraps ValueWidgetProtocol.
17+
18+
Parameters
19+
----------
20+
**base_widget_kwargs : Any
21+
All additional keyword arguments are passed to the base
22+
[`magicgui.widgets.Widget`][magicgui.widgets.Widget] constructor.
23+
"""
24+
25+
_widget: protocols.ToolBarProtocol
26+
27+
def __init__(self, **base_widget_kwargs: Any) -> None:
28+
super().__init__(**base_widget_kwargs)
29+
30+
def add_button(
31+
self, text: str = "", icon: str = "", callback: Callable | None = None
32+
) -> None:
33+
"""Add an action to the toolbar."""
34+
self._widget._mgui_add_button(text, icon, callback)
35+
36+
def add_separator(self) -> None:
37+
"""Add a separator line to the toolbar."""
38+
self._widget._mgui_add_separator()
39+
40+
def add_spacer(self) -> None:
41+
"""Add a spacer to the toolbar."""
42+
self._widget._mgui_add_spacer()
43+
44+
def add_widget(self, widget: Widget) -> None:
45+
"""Add a widget to the toolbar."""
46+
self._widget._mgui_add_widget(widget)
47+
48+
@property
49+
def icon_size(self) -> tuple[int, int] | None:
50+
"""Return the icon size of the toolbar."""
51+
return self._widget._mgui_get_icon_size()
52+
53+
@icon_size.setter
54+
def icon_size(self, size: int | tuple[int, int] | None) -> None:
55+
"""Set the icon size of the toolbar."""
56+
self._widget._mgui_set_icon_size(size)
57+
58+
def clear(self) -> None:
59+
"""Clear the toolbar."""
60+
self._widget._mgui_clear()

src/magicgui/widgets/protocols.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,41 @@ def _mgui_set_margins(self, margins: tuple[int, int, int, int]) -> None:
515515
raise NotImplementedError()
516516

517517

518+
@runtime_checkable
519+
class ToolBarProtocol(WidgetProtocol, Protocol):
520+
"""Toolbar that contains a set of controls."""
521+
522+
@abstractmethod
523+
def _mgui_add_button(
524+
self, text: str, icon: str, callback: Callable | None = None
525+
) -> None:
526+
"""Add a button to the toolbar."""
527+
528+
@abstractmethod
529+
def _mgui_add_separator(self) -> None:
530+
"""Add a separator line to the toolbar."""
531+
532+
@abstractmethod
533+
def _mgui_add_spacer(self) -> None:
534+
"""Add a spacer to the toolbar."""
535+
536+
@abstractmethod
537+
def _mgui_add_widget(self, widget: Widget) -> None:
538+
"""Add a widget to the toolbar."""
539+
540+
@abstractmethod
541+
def _mgui_get_icon_size(self) -> tuple[int, int] | None:
542+
"""Return the icon size of the toolbar."""
543+
544+
@abstractmethod
545+
def _mgui_set_icon_size(self, size: int | tuple[int, int] | None) -> None:
546+
"""Set the icon size of the toolbar."""
547+
548+
@abstractmethod
549+
def _mgui_clear(self) -> None:
550+
"""Clear the toolbar."""
551+
552+
518553
class DialogProtocol(ContainerProtocol, Protocol):
519554
"""Protocol for modal (blocking) containers."""
520555

tests/test_widgets.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,3 +1067,14 @@ def test_float_slider_readout():
10671067
assert sld._widget._readout_widget.value() == 4
10681068
assert sld._widget._readout_widget.minimum() == 0.5
10691069
assert sld._widget._readout_widget.maximum() == 10.5
1070+
1071+
1072+
def test_toolbar():
1073+
tb = widgets.ToolBar()
1074+
tb.add_button("test", callback=lambda: None)
1075+
tb.add_separator()
1076+
tb.add_spacer()
1077+
tb.add_button("test2", callback=lambda: None)
1078+
tb.icon_size = 26
1079+
assert tb.icon_size == (26, 26)
1080+
tb.clear()

0 commit comments

Comments
 (0)