diff --git a/example.py b/example.py new file mode 100644 index 000000000..48e3b2849 --- /dev/null +++ b/example.py @@ -0,0 +1,31 @@ +from magicgui import widgets + +main = widgets.MainWindow() + +# toolbar +tb = widgets.ToolBar() +tb.add_button(text="Folder", icon="mdi:folder") +tb.add_spacer() +tb.add_button(text="Edit", icon="mdi:square-edit-outline") +main.add_tool_bar(tb) + +# status bar +main.status_bar.set_message("Hello Status!", timeout=5000) + +# doc widgets +main.add_dock_widget(widgets.PushButton(text="Push me."), area="right") + +# menus +file_menu = main.menu_bar.add_menu("File") +assert file_menu is main.menu_bar["File"] # can also access like this +file_menu.add_action("Open", callback=lambda: print("Open")) +subm = file_menu.add_menu("Submenu") +subm.add_action("Subaction", callback=lambda: print("Subaction")) +subm.add_separator() +subm.add_action("Subaction2", callback=lambda: print("Subaction2")) + +# central widget +main.append(widgets.Label(value="Central widget")) + +main.height = 400 +main.show(run=True) diff --git a/src/magicgui/application.py b/src/magicgui/application.py index 9df1e3e91..c23242ae6 100644 --- a/src/magicgui/application.py +++ b/src/magicgui/application.py @@ -2,7 +2,7 @@ from __future__ import annotations import signal -from contextlib import contextmanager +from contextlib import contextmanager, suppress from importlib import import_module from typing import TYPE_CHECKING, Any, Callable, Iterator, Union @@ -12,10 +12,23 @@ from types import ModuleType from magicgui.widgets.protocols import BaseApplicationBackend -DEFAULT_BACKEND = "qt" APPLICATION_NAME = "magicgui" +def _in_jupyter() -> bool: + """Return true if we're running in jupyter notebook/lab or qtconsole.""" + with suppress(ImportError): + from IPython import get_ipython + + ipy_class = get_ipython().__class__.__name__ + return bool(ipy_class == "ZMQInteractiveShell") + return False + + +def _choose_backend() -> str: + return "ipynb" if _in_jupyter() else "qt" + + @contextmanager def event_loop(backend: str | None = None) -> Iterator[Application]: """Start an event loop in which to run the application.""" @@ -49,7 +62,7 @@ def backend_module(self) -> ModuleType: def _use(self, backend_name: str | None = None) -> None: """Select a backend by name.""" if not backend_name: - backend_name = DEFAULT_BACKEND + backend_name = _choose_backend() if not backend_name or backend_name.lower() not in BACKENDS: raise ValueError( f"backend_name must be one of {set(BACKENDS)!r}, " diff --git a/src/magicgui/backends/_ipynb/__init__.py b/src/magicgui/backends/_ipynb/__init__.py index b9f98e29a..7e49ada2c 100644 --- a/src/magicgui/backends/_ipynb/__init__.py +++ b/src/magicgui/backends/_ipynb/__init__.py @@ -11,12 +11,16 @@ Label, LineEdit, LiteralEvalLineEdit, + MainWindow, + Menu, + MenuBar, Password, PushButton, RadioButton, Select, Slider, SpinBox, + StatusBar, TextEdit, TimeEdit, ToolBar, @@ -32,22 +36,26 @@ "ComboBox", "Container", "DateEdit", - "TimeEdit", "DateTimeEdit", "EmptyWidget", "FloatSlider", "FloatSpinBox", + "get_text_width", "Label", "LineEdit", "LiteralEvalLineEdit", + "MainWindow", + "Menu", + "MenuBar", "Password", "PushButton", "RadioButton", "Select", + "show_file_dialog", "Slider", "SpinBox", + "StatusBar", "TextEdit", + "TimeEdit", "ToolBar", - "get_text_width", - "show_file_dialog", ] diff --git a/src/magicgui/backends/_ipynb/widgets.py b/src/magicgui/backends/_ipynb/widgets.py index 96cb06ea9..7c5d1b64b 100644 --- a/src/magicgui/backends/_ipynb/widgets.py +++ b/src/magicgui/backends/_ipynb/widgets.py @@ -1,6 +1,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable, Iterable, get_type_hints +import asyncio +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Iterable, + Literal, + get_type_hints, +) try: import ipywidgets @@ -11,11 +19,10 @@ "Please run `pip install ipywidgets`" ) from e - from magicgui.widgets import protocols if TYPE_CHECKING: - from magicgui.widgets.bases import Widget + from magicgui.widgets.bases import MenuWidget, Widget def _pxstr2int(pxstr: int | str) -> int: @@ -526,6 +533,177 @@ def _mgui_get_orientation(self) -> str: return "vertical" if isinstance(self._ipywidget, ipywdg.VBox) else "horizontal" +class IpyMainWindow(ipywdg.GridspecLayout): + IDX_MENUBAR = (0, slice(None)) + IDX_STATUSBAR = (6, slice(None)) + IDX_TOOLBAR_TOP = (1, slice(None)) + IDX_TOOLBAR_BOTTOM = (5, slice(None)) + IDX_TOOLBAR_LEFT = (slice(2, 5), 0) + IDX_TOOLBAR_RIGHT = (slice(2, 5), 4) + IDX_DOCK_TOP = (2, slice(1, 4)) + IDX_DOCK_BOTTOM = (4, slice(1, 4)) + IDX_DOCK_LEFT = (3, 1) + IDX_DOCK_RIGHT = (3, 3) + IDX_CENTRAL_WIDGET = (3, 2) + + def __init__(self, **kwargs): + n_rows = 7 + n_columns = 5 + kwargs.setdefault("width", "600px") + kwargs.setdefault("height", "600px") + super().__init__(n_rows, n_columns, **kwargs) + + hlay = ipywdg.Layout(height="30px", width="auto") + vlay = ipywdg.Layout(height="auto", width="30px") + self[self.IDX_TOOLBAR_TOP] = self._tbars_top = ipywdg.HBox(layout=hlay) + self[self.IDX_TOOLBAR_BOTTOM] = self._tbars_bottom = ipywdg.HBox(layout=hlay) + self[self.IDX_TOOLBAR_LEFT] = self._tbars_left = ipywdg.VBox(layout=vlay) + self[self.IDX_TOOLBAR_RIGHT] = self._tbars_right = ipywdg.VBox(layout=vlay) + self[self.IDX_DOCK_TOP] = self._dwdgs_top = ipywdg.HBox(layout=hlay) + self[self.IDX_DOCK_BOTTOM] = self._dwdgs_bottom = ipywdg.HBox(layout=hlay) + self[self.IDX_DOCK_LEFT] = self._dwdgs_left = ipywdg.VBox(layout=vlay) + self[self.IDX_DOCK_RIGHT] = self._dwdgs_right = ipywdg.VBox(layout=vlay) + + # self.layout.grid_template_columns = "34px 34px 1fr 34px 34px" + # self.layout.grid_template_rows = "34px 34px 34px 1fr 34px 34px 34px" + + def set_menu_bar(self, widget): + self[self.IDX_MENUBAR] = widget + + def set_status_bar(self, widget): + self[self.IDX_STATUSBAR] = widget + + def add_toolbar(self, widget, area: Literal["left", "top", "right", "bottom"]): + if area == "top": + self._tbars_top.children += (widget,) + elif area == "bottom": + self._tbars_bottom.children += (widget,) + elif area == "left": + self._tbars_left.children += (widget,) + elif area == "right": + self._tbars_right.children += (widget,) + else: + raise ValueError(f"Invalid area: {area!r}") + + def add_dock_widget(self, widget, area: Literal["left", "top", "right", "bottom"]): + if area == "top": + self._dwdgs_top.children += (widget,) + elif area == "bottom": + self._dwdgs_bottom.children += (widget,) + elif area == "left": + self._dwdgs_left.children += (widget,) + elif area == "right": + self._dwdgs_right.children += (widget,) + else: + raise ValueError(f"Invalid area: {area!r}") + + +class StatusBar(_IPyWidget, protocols.StatusBarProtocol): + _ipywidget: ipywdg.HBox + + def __init__(self, **kwargs): + super().__init__(ipywdg.HBox, **kwargs) + self._ipywidget.layout.width = "100%" + + self._message_label = ipywdg.Label() + self._buttons = ipywdg.HBox() + # Spacer to push buttons to the right + self._spacer = ipywdg.HBox(layout=ipywdg.Layout(flex="1")) + self._ipywidget.children = (self._message_label, self._spacer, self._buttons) + + def _mgui_get_message(self) -> str: + return self._message_label.value + + def _clear_message(self): + self._message_label.value = "" + + def _mgui_set_message(self, message: str, timeout: int = 0) -> None: + self._message_label.value = message + if timeout > 0: + asyncio.get_event_loop().call_later(timeout / 1000, self._clear_message) + + def _mgui_insert_widget(self, position: int, widget: Widget) -> None: + self._ipywidget.children = ( + *self._ipywidget.children[:position], + widget.native, + *self._ipywidget.children[position:], + ) + + def _mgui_remove_widget(self, widget: Widget) -> None: + self._ipywidget.children = tuple( + child for child in self._ipywidget.children if child != widget.native + ) + + +class MenuBar(_IPyWidget, protocols.MenuBarProtocol): + def _mgui_add_menu_widget(self, widget: MenuWidget) -> None: + ... + + def _mgui_clear(self) -> None: + ... + + +class Menu(_IPyWidget, protocols.MenuProtocol): + def _mgui_add_menu_widget(self, widget: MenuWidget) -> None: + ... + + def _mgui_add_action( + self, + text: str, + shortcut: str | None = None, + icon: str | None = None, + tooltip: str | None = None, + callback: Callable[..., Any] | None = None, + ) -> None: + ... + + def _mgui_clear(self) -> None: + ... + + def _mgui_add_separator(self) -> None: + ... + + def _mgui_get_icon(self) -> str | None: + ... + + def _mgui_set_icon(self, icon: str | None) -> None: + ... + + def _mgui_get_title(self) -> str: + ... + + def _mgui_set_title(self, title: str) -> None: + ... + + +class MainWindow(Container, protocols.MainWindowProtocol): + _ipywidget: IpyMainWindow + + def __init__(self, layout="horizontal", scrollable: bool = False, **kwargs): + self._ipywidget = IpyMainWindow() + + def _mgui_create_menu_item( + self, + menu_name: str, + action_name: str, + callback: Callable | None = None, + shortcut: str | None = None, + ): + pass + + def _mgui_add_dock_widget(self, widget: Widget, area: protocols.Area) -> None: + self._ipywidget.add_dock_widget(widget.native, area) + + def _mgui_add_tool_bar(self, widget: Widget, area: protocols.Area) -> None: + self._ipywidget.add_toolbar(widget.native, area) + + def _mgui_set_status_bar(self, widget: Widget | None) -> None: + self._ipywidget.set_status_bar(widget.native) + + def _mgui_set_menu_bar(self, widget: Widget | None) -> None: + self._ipywidget.set_menu_bar(widget.native) + + def get_text_width(text): # FIXME: how to do this in ipywidgets? return 40 diff --git a/src/magicgui/backends/_qtpy/__init__.py b/src/magicgui/backends/_qtpy/__init__.py index a42b100c3..5ac2f9d87 100644 --- a/src/magicgui/backends/_qtpy/__init__.py +++ b/src/magicgui/backends/_qtpy/__init__.py @@ -15,6 +15,8 @@ LineEdit, LiteralEvalLineEdit, MainWindow, + Menu, + MenuBar, Password, ProgressBar, PushButton, @@ -25,6 +27,7 @@ Select, Slider, SpinBox, + StatusBar, Table, TextEdit, TimeEdit, @@ -51,6 +54,8 @@ "LineEdit", "LiteralEvalLineEdit", "MainWindow", + "MenuBar", + "Menu", "Password", "ProgressBar", "PushButton", @@ -62,6 +67,7 @@ "show_file_dialog", "Slider", "SpinBox", + "StatusBar", "Table", "TextEdit", "TimeEdit", diff --git a/src/magicgui/backends/_qtpy/widgets.py b/src/magicgui/backends/_qtpy/widgets.py index a94bd3665..f12a6a48c 100644 --- a/src/magicgui/backends/_qtpy/widgets.py +++ b/src/magicgui/backends/_qtpy/widgets.py @@ -27,12 +27,15 @@ ) from magicgui.types import FileDialogMode -from magicgui.widgets import Widget, protocols +from magicgui.widgets import protocols from magicgui.widgets._concrete import _LabeledWidget +from magicgui.widgets.bases import MenuWidget, Widget if TYPE_CHECKING: import numpy + from magicgui.widgets.protocols import Area + @contextmanager def _signals_blocked(obj: QtW.QWidget) -> Iterator[None]: @@ -66,10 +69,17 @@ class QBaseWidget(protocols.WidgetProtocol): _qwidget: QtW.QWidget def __init__( - self, qwidg: type[QtW.QWidget], parent: QtW.QWidget | None = None, **kwargs: Any + self, + qwidg: type[QtW.QWidget] | QtW.QWidget, + parent: QtW.QWidget | None = None, + **kwargs: Any, ) -> None: - self._qwidget = qwidg(parent=parent) - self._qwidget.setObjectName(f"magicgui.{qwidg.__name__}") + if isinstance(qwidg, QtW.QWidget): + self._qwidget = qwidg + self._qwidget.setObjectName(f"magicgui.{type(qwidg).__name__}") + else: + self._qwidget = qwidg(parent=parent) + self._qwidget.setObjectName(f"magicgui.{qwidg.__name__}") self._event_filter = EventFilter() self._qwidget.installEventFilter(self._event_filter) @@ -580,26 +590,127 @@ def _mgui_get_orientation(self) -> str: return "vertical" -class MainWindow(Container): +def _add_qmenu(wdg: QtW.QMenu | QtW.QMenuBar, mgui_menu: MenuWidget): + """Add a magicgui menu to a QMenu or QMenuBar.""" + native = mgui_menu.native + if not isinstance(native, QtW.QMenu): + raise TypeError( + f"Expected menu to be a {QtW.QMenu}, got {type(native)}: {native}" + ) + wdg.addMenu(native) + + +class MenuBar(QBaseWidget, protocols.MenuBarProtocol): + _qwidget: QtW.QMenuBar + + def __init__(self, **kwargs: Any) -> None: + super().__init__(QtW.QMenuBar, **kwargs) + + # def _mgui_add_menu(self, title: str, icon: str | None) -> protocols.MenuProtocol: + def _mgui_add_menu_widget(self, widget: MenuWidget) -> None: + """Add a menu to the menu bar.""" + _add_qmenu(self._qwidget, widget) + + def _mgui_clear(self) -> None: + """Clear the menu bar.""" + + +class Menu(QBaseWidget, protocols.MenuProtocol): + _qwidget: QtW.QMenu + + def __init__( + self, qwidg: type[QtW.QMenu] | QtW.QMenu = QtW.QMenu, **kwargs: Any + ) -> None: + super().__init__(qwidg, **kwargs) + + def _mgui_get_title(self) -> str: + return self._qwidget.title() + + def _mgui_set_title(self, value: str) -> None: + self._qwidget.setTitle(value) + + def _mgui_get_icon(self) -> str | None: + # see also: https://github.com/pyapp-kit/superqt/pull/213 + return self._icon + + def _mgui_set_icon(self, icon: str | None) -> None: + self._icon = icon + if icon and (qicon := _get_qicon(icon, None, self._qwidget.palette())): + self._qwidget.setIcon(qicon) + else: + self._qwidget.setIcon(QIcon()) + + def _mgui_add_action( + self, + text: str, + shortcut: str | None = None, + icon: str | None = None, + tooltip: str | None = None, + callback: Callable | None = None, + ) -> None: + """Add an action to the menu.""" + if icon and (qicon := _get_qicon(icon, None, self._qwidget.palette())): + action = self._qwidget.addAction(qicon, text) + else: + action = self._qwidget.addAction(text) + if shortcut: + action.setShortcut(shortcut) + if tooltip: + action.setToolTip(tooltip) + if callback: + action.triggered.connect(callback) + + def _mgui_add_separator(self) -> None: + """Add a separator to the menu.""" + self._qwidget.addSeparator() + + def _mgui_add_menu_widget(self, widget: MenuWidget) -> None: + """Add a menu to the menu bar.""" + _add_qmenu(self._qwidget, widget) + + def _mgui_clear(self) -> None: + """Clear the menu bar.""" + self._qwidget.clear() + + +class MainWindow(QBaseWidget, protocols.MainWindowProtocol): + _qwidget: QtW.QMainWindow + def __init__( self, layout="vertical", scrollable: bool = False, **kwargs: Any ) -> None: - super().__init__(layout=layout, scrollable=scrollable, **kwargs) - self._main_window = QtW.QMainWindow() - self._menus: dict[str, QtW.QMenu] = {} - if scrollable: - self._main_window.setCentralWidget(self._scroll) + QBaseWidget.__init__(self, QtW.QMainWindow, **kwargs) + if layout == "horizontal": + self._layout: QtW.QBoxLayout = QtW.QHBoxLayout() else: - self._main_window.setCentralWidget(self._qwidget) + self._layout = QtW.QVBoxLayout() + self._central_widget = QtW.QWidget() + self._central_widget.setLayout(self._layout) - def _mgui_get_visible(self): - return self._main_window.isVisible() + if scrollable: + self._scroll = QtW.QScrollArea() + # Allow widget to resize when window is larger than min widget size + self._scroll.setWidgetResizable(True) + if layout == "horizontal": + horiz_policy = Qt.ScrollBarPolicy.ScrollBarAsNeeded + vert_policy = Qt.ScrollBarPolicy.ScrollBarAlwaysOff + else: + horiz_policy = Qt.ScrollBarPolicy.ScrollBarAlwaysOff + vert_policy = Qt.ScrollBarPolicy.ScrollBarAsNeeded + self._scroll.setHorizontalScrollBarPolicy(horiz_policy) + self._scroll.setVerticalScrollBarPolicy(vert_policy) + self._scroll.setWidget(self._central_widget) + self._central_widget = self._scroll - def _mgui_set_visible(self, value: bool): - self._main_window.setVisible(value) + self._menus: dict[str, QtW.QMenu] = {} + if scrollable: + self._qwidget.setCentralWidget(self._scroll) + else: + self._qwidget.setCentralWidget(self._central_widget) - def _mgui_get_native_widget(self) -> QtW.QMainWindow: - return self._main_window + @property + def _is_scrollable(self) -> bool: + return isinstance(self._central_widget, QtW.QScrollArea) def _mgui_create_menu_item( self, @@ -609,15 +720,105 @@ def _mgui_create_menu_item( shortcut: str | None = None, ): menu = self._menus.setdefault( - menu_name, self._main_window.menuBar().addMenu(f"&{menu_name}") + menu_name, self._qwidget.menuBar().addMenu(f"&{menu_name}") ) - action = QtW.QAction(action_name, self._main_window) + action = QtW.QAction(action_name, self._qwidget) if shortcut is not None: action.setShortcut(shortcut) if callback is not None: action.triggered.connect(callback) menu.addAction(action) + def _mgui_add_tool_bar(self, widget: Widget, area: Area) -> None: + native = widget.native + if not isinstance(native, QtW.QToolBar): + raise TypeError( + f"Expected widget to be a {QtW.QToolBar}, got {type(native)}" + ) + self._qwidget.addToolBar(Q_TB_AREA[area], native) + + def _mgui_add_dock_widget(self, widget: Widget, area: Area) -> None: + native = widget.native + if isinstance(native, QtW.QDockWidget): + dw = native + else: + # TODO: allowed areas + dw = QtW.QDockWidget() + dw.setWidget(native) + self._qwidget.addDockWidget(Q_DW_AREA[area], dw) + + def _mgui_set_status_bar(self, widget: Widget | None) -> None: + if widget is None: + self._qwidget.setStatusBar(None) + return + + native = widget.native + if not isinstance(native, QtW.QStatusBar): + raise TypeError( + f"Expected widget to be a {QtW.QStatusBar}, got {type(native)}" + ) + self._qwidget.setStatusBar(native) + + def _mgui_set_menu_bar(self, widget: Widget | None) -> None: + if widget is None: + self._qwidget.setMenuBar(QtW.QMenuBar()) + return + + native = widget.native + if not isinstance(native, QtW.QMenuBar): + raise TypeError( + f"Expected widget to be a {QtW.QMenuBar}, got {type(native)}" + ) + self._qwidget.setMenuBar(native) + + def _mgui_insert_widget(self, position: int, widget: Widget): + self._layout.insertWidget(position, widget.native) + if self._is_scrollable: + min_size = self._layout.totalMinimumSize() + if isinstance(self._layout, QtW.QHBoxLayout): + self._scroll.setMinimumHeight(min_size.height()) + else: + self._scroll.setMinimumWidth(min_size.width() + 20) + + def _mgui_remove_widget(self, widget: Widget): + self._layout.removeWidget(widget.native) + widget.native.setParent(None) + + def _mgui_get_margins(self) -> tuple[int, int, int, int]: + m = self._layout.contentsMargins() + return m.left(), m.top(), m.right(), m.bottom() + + def _mgui_set_margins(self, margins: tuple[int, int, int, int]) -> None: + self._layout.setContentsMargins(*margins) + + def _mgui_set_orientation(self, value) -> None: + """Set orientation, value will be 'horizontal' or 'vertical'.""" + raise NotImplementedError( + "Sorry, changing orientation after instantiation " + "is not yet implemented for Qt." + ) + + def _mgui_get_orientation(self) -> str: + """Set orientation, return either 'horizontal' or 'vertical'.""" + if isinstance(self, QtW.QHBoxLayout): + return "horizontal" + else: + return "vertical" + + +Q_TB_AREA: dict[Area, Qt.ToolBarArea] = { + "top": Qt.ToolBarArea.TopToolBarArea, + "bottom": Qt.ToolBarArea.BottomToolBarArea, + "left": Qt.ToolBarArea.LeftToolBarArea, + "right": Qt.ToolBarArea.RightToolBarArea, +} +Q_DW_AREA: dict[Area, Qt.DockWidgetArea] = { + "top": Qt.DockWidgetArea.TopDockWidgetArea, + "bottom": Qt.DockWidgetArea.BottomDockWidgetArea, + "left": Qt.DockWidgetArea.LeftDockWidgetArea, + "right": Qt.DockWidgetArea.RightDockWidgetArea, +} + class SpinBox(QBaseRangedWidget): def __init__(self, **kwargs: Any) -> None: @@ -1217,7 +1418,7 @@ def _mgui_get_value(self): return self._qwidget.time().toPyTime() -class ToolBar(QBaseWidget): +class ToolBar(QBaseWidget, protocols.ToolBarProtocol): _qwidget: QtW.QToolBar def __init__(self, **kwargs: Any) -> None: @@ -1233,7 +1434,10 @@ def _on_palette_change(self): 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 callback: + act = self._qwidget.addAction(text, callback) + else: + act = self._qwidget.addAction(text) if qicon := _get_qicon(icon, None, palette=self._qwidget.palette()): act.setIcon(qicon) act.setData(icon) @@ -1274,6 +1478,35 @@ def _mgui_clear(self) -> None: self._qwidget.clear() +class StatusBar(QBaseWidget, protocols.StatusBarProtocol): + _qwidget: QtW.QStatusBar + + def __init__(self, **kwargs: Any) -> None: + super().__init__(QtW.QStatusBar, **kwargs) + + def _mgui_insert_widget(self, position: int, widget: Widget) -> None: + """Insert `widget` at the given `position`.""" + self._qwidget.insertWidget(position, widget.native) + + def _mgui_remove_widget(self, widget: Widget) -> None: + """Remove the specified widget.""" + self._qwidget.removeWidget(widget.native) + + def _mgui_get_message(self) -> str: + """Return currently shown message, or empty string if None.""" + return self._qwidget.currentMessage() + + def _mgui_set_message(self, message: str, timeout: int = 0) -> None: + """Show a message in the status bar for a given timeout. + + To clear the message, set it to the empty string + """ + if message: + self._qwidget.showMessage(message, timeout) + else: + self._qwidget.clearMessage() + + 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 2478afb0d..02f4662fa 100644 --- a/src/magicgui/widgets/__init__.py +++ b/src/magicgui/widgets/__init__.py @@ -41,6 +41,7 @@ SliceEdit, Slider, SpinBox, + StatusBar, TextEdit, TimeEdit, ToolBar, @@ -105,6 +106,7 @@ "SliceEdit", "Slider", "SpinBox", + "StatusBar", "Table", "TextEdit", "TimeEdit", diff --git a/src/magicgui/widgets/_concrete.py b/src/magicgui/widgets/_concrete.py index 9b60a4447..787649924 100644 --- a/src/magicgui/widgets/_concrete.py +++ b/src/magicgui/widgets/_concrete.py @@ -43,9 +43,12 @@ ContainerWidget, DialogWidget, MainWindowWidget, + MenuBarWidget, + MenuWidget, MultiValuedSliderWidget, RangedWidget, SliderWidget, + StatusBarWidget, ToolBarWidget, TransformedRangedWidget, ValueWidget, @@ -967,6 +970,21 @@ class ToolBar(ToolBarWidget): """Toolbar that contains a set of controls.""" +@backend_widget +class StatusBar(StatusBarWidget): + """Status bar that displays status information.""" + + +@backend_widget +class MenuBar(MenuBarWidget): + """Menu bar that contains multiple menus.""" + + +@backend_widget +class Menu(MenuWidget): + """A menu that contains actions.""" + + 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 176a12e9f..b0882dbf4 100644 --- a/src/magicgui/widgets/bases/__init__.py +++ b/src/magicgui/widgets/bases/__init__.py @@ -44,10 +44,13 @@ def __init__( """ from ._button_widget import ButtonWidget from ._categorical_widget import CategoricalWidget -from ._container_widget import ContainerWidget, DialogWidget, MainWindowWidget +from ._container_widget import ContainerWidget, DialogWidget from ._create_widget import create_widget +from ._main_window import MainWindowWidget +from ._menubar import MenuBarWidget, MenuWidget from ._ranged_widget import RangedWidget, TransformedRangedWidget from ._slider_widget import MultiValuedSliderWidget, SliderWidget +from ._statusbar import StatusBarWidget from ._toolbar import ToolBarWidget from ._value_widget import ValueWidget from ._widget import Widget @@ -59,9 +62,12 @@ def __init__( "create_widget", "DialogWidget", "MainWindowWidget", + "MenuBarWidget", + "MenuWidget", "MultiValuedSliderWidget", "RangedWidget", "SliderWidget", + "StatusBarWidget", "ToolBarWidget", "TransformedRangedWidget", "ValueWidget", diff --git a/src/magicgui/widgets/bases/_container_widget.py b/src/magicgui/widgets/bases/_container_widget.py index a1fc17ee9..a08066ed8 100644 --- a/src/magicgui/widgets/bases/_container_widget.py +++ b/src/magicgui/widgets/bases/_container_widget.py @@ -404,25 +404,6 @@ def _load(self, path: str | Path, quiet: bool = False) -> None: getattr(self, key).value = val -class MainWindowWidget(ContainerWidget): - """Top level Application widget that can contain other widgets.""" - - _widget: protocols.MainWindowProtocol - - def create_menu_item( - self, - menu_name: str, - item_name: str, - callback: Callable | None = None, - shortcut: str | None = None, - ) -> None: - """Create a menu item ``item_name`` under menu ``menu_name``. - - ``menu_name`` will be created if it does not already exist. - """ - self._widget._mgui_create_menu_item(menu_name, item_name, callback, shortcut) - - class DialogWidget(ContainerWidget): """Modal Container.""" diff --git a/src/magicgui/widgets/bases/_main_window.py b/src/magicgui/widgets/bases/_main_window.py new file mode 100644 index 000000000..5fcd3fd68 --- /dev/null +++ b/src/magicgui/widgets/bases/_main_window.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable, cast + +from ._container_widget import ContainerWidget + +if TYPE_CHECKING: + from magicgui.widgets import protocols + from magicgui.widgets._concrete import MenuBar, StatusBar + + from ._widget import Widget + + +class MainWindowWidget(ContainerWidget): + """Top level Application widget that can contain other widgets.""" + + _widget: protocols.MainWindowProtocol + _status_bar: StatusBar | None = None + _menu_bar: MenuBar | None = None + + def create_menu_item( + self, + menu_name: str, + item_name: str, + callback: Callable | None = None, + shortcut: str | None = None, + ) -> None: + """Create a menu item ``item_name`` under menu ``menu_name``. + + ``menu_name`` will be created if it does not already exist. + """ + self._widget._mgui_create_menu_item(menu_name, item_name, callback, shortcut) + + def add_dock_widget( + self, widget: Widget, *, area: protocols.Area = "right" + ) -> None: + """Add a dock widget to the main window. + + Parameters + ---------- + widget : Widget + The widget to add to the main window. + area : str, optional + The area in which to add the widget, must be one of + `{'left', 'right', 'top', 'bottom'}`, by default "right". + """ + self._widget._mgui_add_dock_widget(widget, area) + + def add_tool_bar(self, widget: Widget, *, area: protocols.Area = "top") -> None: + """Add a toolbar to the main window. + + Parameters + ---------- + widget : Widget + The widget to add to the main window. + area : str, optional + The area in which to add the widget, must be one of + `{'left', 'right', 'top', 'bottom'}`, by default "top". + """ + self._widget._mgui_add_tool_bar(widget, area) + + @property + def menu_bar(self) -> MenuBar: + """Return the status bar widget.""" + if self._menu_bar is None: + from magicgui.widgets._concrete import MenuBar + + self.menu_bar = MenuBar() + return cast("MenuBar", self._menu_bar) + + @menu_bar.setter + def menu_bar(self, widget: MenuBar | None) -> None: + """Set the status bar widget.""" + self._menu_bar = widget + self._widget._mgui_set_menu_bar(widget) + + @property + def status_bar(self) -> StatusBar: + """Return the status bar widget.""" + if self._status_bar is None: + from magicgui.widgets._concrete import StatusBar + + self.status_bar = StatusBar() + return cast("StatusBar", self._status_bar) + + @status_bar.setter + def status_bar(self, widget: StatusBar | None) -> None: + """Set the status bar widget.""" + self._status_bar = widget + self._widget._mgui_set_status_bar(widget) + + # def set_status_bar(self, widget: Widget) -> None: + # """Set the statusbar of the main window. + + # Parameters + # ---------- + # widget : Widget + # The widget to add to the main window. + # """ + # self._widget._mgui_set_status_bar(widget) + + # def set_menubar(self, widget: Widget) -> None: + # """Set the menubar of the main window. + + # Parameters + # ---------- + # widget : Widget + # The widget to add to the main window. + # """ + # self._widget._mgui_set_menu_bar(widget) diff --git a/src/magicgui/widgets/bases/_menubar.py b/src/magicgui/widgets/bases/_menubar.py new file mode 100644 index 000000000..b66ae99ea --- /dev/null +++ b/src/magicgui/widgets/bases/_menubar.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, overload + +from ._widget import Widget + +if TYPE_CHECKING: + from magicgui.widgets import protocols + from magicgui.widgets._concrete import Menu + + +class _SupportsMenus: + """Mixin for widgets that support menus.""" + + _widget: protocols.MenuBarProtocol | protocols.MenuProtocol + + def __init__(self, *args: Any, **kwargs: Any): + self._menus: dict[str, MenuWidget] = {} + super().__init__(*args, **kwargs) + + def __getitem__(self, key: str) -> MenuWidget: + return self._menus[key] + + @overload + def add_menu(self, widget: Menu) -> MenuWidget: + ... + + @overload + def add_menu(self, title: str, icon: str | None = None) -> MenuWidget: + ... + + def add_menu( + self, + *args: Any, + widget: Menu | None = None, + title: str = "", + icon: str | None = None, + ) -> MenuWidget: + """Add a menu to the menu bar.""" + widget = _parse_menu_overload(args, widget, title, icon) + self._menus[widget.title] = widget + self._widget._mgui_add_menu_widget(widget) + return widget + + +def _parse_menu_overload( + args: tuple, widget: Menu | None = None, title: str = "", icon: str | None = None +) -> Menu: + from magicgui.widgets._concrete import Menu + + if len(args) == 2: + title, icon = args + elif len(args) == 1: + if not isinstance(arg0 := args[0], (str, Menu)): + raise TypeError("First argument must be a string or Menu") + if isinstance(arg0, Menu): + widget = arg0 + else: + title = arg0 + + if widget is None: + widget = Menu(title=title, icon=icon) + return widget + + +class MenuBarWidget(_SupportsMenus, Widget): + """Menu bar containing menus. Can be added to a MainWindowWidget.""" + + _widget: protocols.MenuBarProtocol + + def __init__(self, **base_widget_kwargs: Any) -> None: + super().__init__(**base_widget_kwargs) + + def clear(self) -> None: + """Clear the menu bar.""" + self._widget._mgui_clear() + + +class MenuWidget(_SupportsMenus, Widget): + """Menu widget. Can be added to a MenuBarWidget or another MenuWidget.""" + + _widget: protocols.MenuProtocol + + def __init__( + self, title: str = "", icon: str | None = "", **base_widget_kwargs: Any + ) -> None: + super().__init__(**base_widget_kwargs) + self.title = title + self.icon = icon + + @property + def title(self) -> str: + """Title of the menu.""" + return self._widget._mgui_get_title() + + @title.setter + def title(self, value: str) -> None: + self._widget._mgui_set_title(value) + + @property + def icon(self) -> str | None: + """Icon of the menu.""" + return self._widget._mgui_get_icon() + + @icon.setter + def icon(self, value: str | None) -> None: + self._widget._mgui_set_icon(value) + + def add_action( + self, + text: str, + shortcut: str | None = None, + icon: str | None = None, + tooltip: str | None = None, + callback: Callable | None = None, + ) -> None: + """Add an action to the menu.""" + self._widget._mgui_add_action(text, shortcut, icon, tooltip, callback) + + def add_separator(self) -> None: + """Add a separator line to the menu.""" + self._widget._mgui_add_separator() + + def clear(self) -> None: + """Clear the menu.""" + self._widget._mgui_clear() diff --git a/src/magicgui/widgets/bases/_statusbar.py b/src/magicgui/widgets/bases/_statusbar.py new file mode 100644 index 000000000..332840134 --- /dev/null +++ b/src/magicgui/widgets/bases/_statusbar.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ._widget import Widget + +if TYPE_CHECKING: + from magicgui.widgets import protocols + + +class StatusBarWidget(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.StatusBarProtocol + + def __init__(self, **base_widget_kwargs: Any) -> None: + super().__init__(**base_widget_kwargs) + + def add_widget(self, widget: Widget) -> None: + """Add a widget to the toolbar.""" + self.insert_widget(-1, widget) + + def insert_widget(self, position: int, widget: Widget) -> None: + """Insert a widget at the given position.""" + self._widget._mgui_insert_widget(position, widget) + + def remove_widget(self, widget: Widget) -> None: + """Remove a widget from the toolbar.""" + self._widget._mgui_remove_widget(widget) + + @property + def message(self) -> str: + """Return currently shown message, or empty string if None.""" + return self._widget._mgui_get_message() + + @message.setter + def message(self, message: str) -> None: + """Return the message timeout in milliseconds.""" + self.set_message(message) + + def set_message(self, message: str, timeout: int = 0) -> None: + """Show a message in the status bar for a given timeout. + + To clear the message, set it to the empty string + """ + self._widget._mgui_set_message(message, timeout) diff --git a/src/magicgui/widgets/bases/_toolbar.py b/src/magicgui/widgets/bases/_toolbar.py index c8d87766e..727791ca5 100644 --- a/src/magicgui/widgets/bases/_toolbar.py +++ b/src/magicgui/widgets/bases/_toolbar.py @@ -1,16 +1,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable, Tuple, TypeVar, Union +from typing import TYPE_CHECKING, Any, Callable 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. diff --git a/src/magicgui/widgets/protocols.py b/src/magicgui/widgets/protocols.py index 952dc8fdd..920d79f65 100644 --- a/src/magicgui/widgets/protocols.py +++ b/src/magicgui/widgets/protocols.py @@ -15,6 +15,7 @@ Any, Callable, Iterable, + Literal, NoReturn, Protocol, Sequence, @@ -24,7 +25,9 @@ if TYPE_CHECKING: import numpy as np - from magicgui.widgets.bases import Widget + from magicgui.widgets.bases import MenuWidget, Widget + + Area = Literal["left", "right", "top", "bottom"] def assert_protocol(widget_class: type, protocol: type) -> None | NoReturn: @@ -550,6 +553,109 @@ def _mgui_clear(self) -> None: """Clear the toolbar.""" +class StatusBarProtocol(WidgetProtocol, Protocol): + """Status bar that contains a set of controls.""" + + @abstractmethod + def _mgui_insert_widget(self, position: int, widget: Widget) -> None: + """Insert `widget` at the given `position`.""" + raise NotImplementedError() + + @abstractmethod + def _mgui_remove_widget(self, widget: Widget) -> None: + """Remove the specified widget.""" + raise NotImplementedError() + + @abstractmethod + def _mgui_get_message(self) -> str: + """Return currently shown message, or empty string if None.""" + raise NotImplementedError() + + @abstractmethod + def _mgui_set_message(self, message: str, timeout: int = 0) -> None: + """Show a message in the status bar for a given timeout. + + To clear the message, set it to the empty string + """ + raise NotImplementedError() + + +class MenuBarProtocol(WidgetProtocol, Protocol): + """Menu bar that contains a set of menus.""" + + # @abstractmethod + # def _mgui_add_menu(self, title: str, icon: str | None) -> MenuProtocol: + # """Add a menu to the menu bar.""" + # raise NotImplementedError() + + @abstractmethod + def _mgui_add_menu_widget(self, widget: MenuWidget) -> None: + """Add a menu to the menu bar.""" + raise NotImplementedError() + + @abstractmethod + def _mgui_clear(self) -> None: + """Clear the menu bar.""" + raise NotImplementedError() + + +class MenuProtocol(WidgetProtocol, Protocol): + """Menu that contains a set of actions.""" + + # @abstractmethod + # def _mgui_insert_action(self, before: str | None, action: Widget) -> None: + # """Insert action before the specified action.""" + # raise NotImplementedError() + + @abstractmethod + def _mgui_get_title(self) -> str: + """Return the title of the menu.""" + raise NotImplementedError() + + @abstractmethod + def _mgui_set_title(self, title: str) -> None: + """Set the title of the menu.""" + raise NotImplementedError() + + @abstractmethod + def _mgui_get_icon(self) -> str | None: + """Return the icon of the menu.""" + raise NotImplementedError() + + @abstractmethod + def _mgui_set_icon(self, icon: str | None) -> None: + """Set the icon of the menu.""" + raise NotImplementedError() + + @abstractmethod + def _mgui_add_action( + self, + text: str, + shortcut: str | None = None, + icon: str | None = None, + tooltip: str | None = None, + callback: Callable | None = None, + ) -> None: + """Add an action to the menu.""" + + @abstractmethod + def _mgui_add_separator(self) -> None: + """Add a separator line to the menu.""" + + @abstractmethod + def _mgui_add_menu_widget(self, widget: MenuWidget) -> None: + """Add a menu to the menu bar.""" + raise NotImplementedError() + + # @abstractmethod + # def _mgui_add_menu(self, title: str, icon: str | None) -> None: + # """Add a menu to the menu.""" + + @abstractmethod + def _mgui_clear(self) -> None: + """Clear the menu bar.""" + + class DialogProtocol(ContainerProtocol, Protocol): """Protocol for modal (blocking) containers.""" @@ -585,6 +691,22 @@ def _mgui_create_menu_item( """ raise NotImplementedError() + @abstractmethod + def _mgui_add_dock_widget(self, widget: Widget, area: Area) -> None: + raise NotImplementedError() + + @abstractmethod + def _mgui_add_tool_bar(self, widget: Widget, area: Area) -> None: + raise NotImplementedError() + + @abstractmethod + def _mgui_set_menu_bar(self, widget: Widget | None) -> None: + raise NotImplementedError() + + @abstractmethod + def _mgui_set_status_bar(self, widget: Widget | None) -> None: + raise NotImplementedError() + # APPLICATION -------------------------------------------------------------------- diff --git a/tests/test_main_window.py b/tests/test_main_window.py new file mode 100644 index 000000000..90eff72c0 --- /dev/null +++ b/tests/test_main_window.py @@ -0,0 +1,34 @@ +from magicgui import magicgui, widgets + + +def test_main_function_gui(): + """Test that main_window makes the widget a top level main window with menus.""" + + @magicgui(main_window=True) + def add(num1: int, num2: int) -> int: + """Adds the given two numbers, returning the result. + + The function assumes that the two numbers can be added and does + not perform any prior checks. + + Parameters + ---------- + num1 , num2 : int + Numbers to be added + + Returns + ------- + int + Resulting integer + """ + + assert not add.visible + add.show() + assert add.visible + + assert isinstance(add, widgets.MainFunctionGui) + add._show_docs() + assert isinstance(add._help_text_edit, widgets.TextEdit) + assert add._help_text_edit.value.startswith("Adds the given two numbers") + assert add._help_text_edit.read_only + add.close() diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 99df22cf1..399c7981b 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -400,39 +400,6 @@ def t(pbar: widgets.ProgressBar): assert t() == 23 -def test_main_function_gui(): - """Test that main_window makes the widget a top level main window with menus.""" - - @magicgui(main_window=True) - def add(num1: int, num2: int) -> int: - """Adds the given two numbers, returning the result. - - The function assumes that the two numbers can be added and does - not perform any prior checks. - - Parameters - ---------- - num1 , num2 : int - Numbers to be added - - Returns - ------- - int - Resulting integer - """ - - assert not add.visible - add.show() - assert add.visible - - assert isinstance(add, widgets.MainFunctionGui) - add._show_docs() - assert isinstance(add._help_text_edit, widgets.TextEdit) - assert add._help_text_edit.value.startswith("Adds the given two numbers") - assert add._help_text_edit.read_only - add.close() - - def test_range_widget(): args = (-100, 1000, 2) rw = widgets.RangeEdit(*args)