diff --git a/docs/examples/applications/main_window.py b/docs/examples/applications/main_window.py new file mode 100644 index 000000000..4ddab23c4 --- /dev/null +++ b/docs/examples/applications/main_window.py @@ -0,0 +1,39 @@ +"""# Main window + +Use `MainWindow` to build an application shell with a menu bar, tool bars, +dock widgets, and a status bar around a central widget area. +""" + +from magicgui import widgets + +main = widgets.MainWindow() + +# toolbar +toolbar = widgets.ToolBar() +toolbar.add_button(text="Folder", icon="mdi:folder") +toolbar.add_spacer() +toolbar.add_button(text="Edit", icon="mdi:square-edit-outline") +main.add_tool_bar(toolbar, area="top") + +# status bar +main.status_bar.set_message("Hello Status!", timeout=5000) + +# dock 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")) +submenu = file_menu.add_menu("Submenu") +submenu.add_action("Subaction", callback=lambda: print("Subaction")) +submenu.add_separator() +submenu.add_action("Subaction2", callback=lambda: print("Subaction2")) + +# central widget +main.append(widgets.Label(value="Central widget")) + +main.height = 400 + +if __name__ == "__main__": + main.show(run=True) diff --git a/src/magicgui/backends/_ipynb/__init__.py b/src/magicgui/backends/_ipynb/__init__.py index aae636d5f..7a1f6a19d 100644 --- a/src/magicgui/backends/_ipynb/__init__.py +++ b/src/magicgui/backends/_ipynb/__init__.py @@ -12,6 +12,9 @@ Label, LineEdit, LiteralEvalLineEdit, + MainWindow, + Menu, + MenuBar, Password, ProgressBar, PushButton, @@ -21,6 +24,7 @@ Select, Slider, SpinBox, + StatusBar, TextEdit, TimeEdit, ToolBar, @@ -44,6 +48,9 @@ "Label", "LineEdit", "LiteralEvalLineEdit", + "MainWindow", + "Menu", + "MenuBar", "Password", "ProgressBar", "PushButton", @@ -53,6 +60,7 @@ "Select", "Slider", "SpinBox", + "StatusBar", "TextEdit", "TimeEdit", "ToolBar", diff --git a/src/magicgui/backends/_ipynb/widgets.py b/src/magicgui/backends/_ipynb/widgets.py index 5cc51f372..28196653f 100644 --- a/src/magicgui/backends/_ipynb/widgets.py +++ b/src/magicgui/backends/_ipynb/widgets.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from collections.abc import Callable from typing import TYPE_CHECKING, Any, get_type_hints @@ -18,8 +19,9 @@ if TYPE_CHECKING: from collections.abc import Iterable + from typing import Literal - from magicgui.widgets.bases import Widget + from magicgui.widgets.bases import MenuWidget, Widget def _pxstr2int(pxstr: int | str) -> int: @@ -516,41 +518,44 @@ class Container(_IPyWidget, protocols.ContainerProtocol, protocols.SupportsOrien def __init__(self, layout="horizontal", scrollable: bool = False, **kwargs): wdg_class = ipywidgets.VBox if layout == "vertical" else ipywidgets.HBox super().__init__(wdg_class, **kwargs) + # the box that holds the children. Subclasses (e.g. MainWindow) may + # point this at an inner box rather than the outermost widget. + self._box: ipywdg.Box = self._ipywidget def _mgui_add_widget(self, widget: Widget) -> None: - children = list(self._ipywidget.children) + children = list(self._box.children) children.append(widget.native) - self._ipywidget.children = children - widget.parent = self._ipywidget + self._box.children = children + widget.parent = self._box def _mgui_insert_widget(self, position: int, widget: Widget) -> None: - children = list(self._ipywidget.children) + children = list(self._box.children) children.insert(position, widget.native) - self._ipywidget.children = children - widget.parent = self._ipywidget + self._box.children = children + widget.parent = self._box def _mgui_remove_widget(self, widget: Widget) -> None: - children = list(self._ipywidget.children) + children = list(self._box.children) children.remove(widget.native) - self._ipywidget.children = children + self._box.children = children def _mgui_remove_index(self, position: int) -> None: - children = list(self._ipywidget.children) + children = list(self._box.children) children.pop(position) - self._ipywidget.children = children + self._box.children = children def _mgui_count(self) -> int: - return len(self._ipywidget.children) + return len(self._box.children) def _mgui_index(self, widget: Widget) -> int: - return self._ipywidget.children.index(widget.native) + return self._box.children.index(widget.native) def _mgui_get_index(self, index: int) -> Widget | None: """(return None instead of index error).""" - return self._ipywidget.children[index]._magic_widget + return self._box.children[index]._magic_widget def _mgui_get_native_layout(self) -> Any: - raise self._ipywidget + return self._box def _mgui_get_margins(self) -> tuple[int, int, int, int]: margin = self._ipywidget.layout.margin @@ -573,7 +578,268 @@ def _mgui_set_orientation(self, value) -> None: ) def _mgui_get_orientation(self) -> str: - return "vertical" if isinstance(self._ipywidget, ipywdg.VBox) else "horizontal" + return "vertical" if isinstance(self._box, 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) + + # NOTE: each box needs its own Layout instance, because + # GridspecLayout.__setitem__ writes the cell position into + # child.layout.grid_area + def _box(box_cls: type) -> ipywdg.Box: + return box_cls(layout=ipywdg.Layout(height="auto", width="auto")) + + self[self.IDX_TOOLBAR_TOP] = self._tbars_top = _box(ipywdg.HBox) + self[self.IDX_TOOLBAR_BOTTOM] = self._tbars_bottom = _box(ipywdg.HBox) + self[self.IDX_TOOLBAR_LEFT] = self._tbars_left = _box(ipywdg.VBox) + self[self.IDX_TOOLBAR_RIGHT] = self._tbars_right = _box(ipywdg.VBox) + self[self.IDX_DOCK_TOP] = self._dwdgs_top = _box(ipywdg.HBox) + self[self.IDX_DOCK_BOTTOM] = self._dwdgs_bottom = _box(ipywdg.HBox) + self[self.IDX_DOCK_LEFT] = self._dwdgs_left = _box(ipywdg.VBox) + self[self.IDX_DOCK_RIGHT] = self._dwdgs_right = _box(ipywdg.VBox) + + # empty bars/docks collapse; the central widget gets the rest. + # These private attributes are what GridspecLayout._update_layout + # re-applies to self.layout on every __setitem__. + self._grid_template_columns = "auto auto 1fr auto auto" + self._grid_template_rows = "auto auto auto 1fr auto auto auto" + self._update_layout() + + def set_menu_bar(self, widget: ipywdg.Widget | None) -> None: + self[self.IDX_MENUBAR] = ipywdg.Box() if widget is None else widget + + def set_status_bar(self, widget: ipywdg.Widget | None) -> None: + self[self.IDX_STATUSBAR] = ipywdg.Box() if widget is None else widget + + def add_toolbar(self, widget, area: Literal["left", "top", "right", "bottom"]): + # let the toolbar fill its bar area so spacers can expand, as in Qt + widget.layout.flex = "1 1 auto" + 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() + # spacer pushes added widgets to the right, like Qt permanent widgets + self._spacer = ipywdg.HBox(layout=ipywdg.Layout(flex="1")) + self._widgets: list[ipywdg.Widget] = [] + self._sync() + + def _sync(self) -> None: + self._ipywidget.children = (self._message_label, self._spacer, *self._widgets) + + 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: + try: + loop = asyncio.get_running_loop() + except RuntimeError: # no event loop (e.g. bare interpreter) + pass + else: + loop.call_later(timeout / 1000, self._clear_message) + + def _mgui_insert_widget(self, position: int, widget: Widget) -> None: + if position < 0: # negative positions append, as in Qt + self._widgets.append(widget.native) + else: + self._widgets.insert(position, widget.native) + self._sync() + + def _mgui_remove_widget(self, widget: Widget) -> None: + self._widgets = [wdg for wdg in self._widgets if wdg is not widget.native] + self._sync() + + +class MenuBar(_IPyWidget, protocols.MenuBarProtocol): + """Menu bar implemented as a horizontal row of dropdown menus.""" + + _ipywidget: ipywdg.HBox + + def __init__(self, **kwargs): + super().__init__(ipywdg.HBox, **kwargs) + + def _mgui_add_menu_widget(self, widget: MenuWidget) -> None: + self._ipywidget.children = (*self._ipywidget.children, widget.native) + + def _mgui_clear(self) -> None: + self._ipywidget.children = () + + +class Menu(_IPyWidget, protocols.MenuProtocol): + """Menu implemented as a Dropdown. + + The first entry shows the menu title and acts as a placeholder; selecting + any other entry triggers that action's callback and resets the selection + back to the title. + """ + + _ipywidget: ipywdg.Dropdown + _TITLE = "__title__" + + def __init__(self, **kwargs): + self._title = "" + self._icon: str | None = None + # value -> (label, callback); insertion order is the menu order + self._items: dict[str, tuple[str, Callable | None]] = {} + self._n_items = 0 + self._syncing = False + super().__init__(ipywdg.Dropdown, **kwargs) + self._ipywidget.layout.width = "auto" + self._ipywidget.observe(self._on_select, names=["value"]) + self._sync_options() + + def _sync_options(self) -> None: + self._syncing = True + try: + self._ipywidget.options = [(self._title, self._TITLE)] + [ + (label, value) for value, (label, _) in self._items.items() + ] + self._ipywidget.value = self._TITLE + finally: + self._syncing = False + + def _on_select(self, change: dict) -> None: + if self._syncing: + return + value = change.get("new") + if value == self._TITLE or value is None: + return + _, callback = self._items.get(value, ("", None)) + # reset back to the title before invoking the callback + self._syncing = True + try: + self._ipywidget.value = self._TITLE + finally: + self._syncing = False + if callback is not None: + callback() + + def _mgui_get_title(self) -> str: + return self._title + + def _mgui_set_title(self, title: str) -> None: + self._title = title + self._sync_options() + + def _mgui_get_icon(self) -> str | None: + return self._icon + + def _mgui_set_icon(self, icon: str | None) -> None: + # icons are not (yet) rendered in the ipynb backend + self._icon = icon + + 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: + # shortcut/icon/tooltip are not (yet) supported in the ipynb backend + self._n_items += 1 + self._items[f"action_{self._n_items}"] = (text, callback) + self._sync_options() + + def _mgui_add_separator(self) -> None: + self._n_items += 1 + self._items[f"separator_{self._n_items}"] = ("─" * 6, None) + self._sync_options() + + def _mgui_add_menu_widget(self, widget: MenuWidget) -> None: + raise NotImplementedError( + "Nested menus are not yet supported in the ipynb backend" + ) + + def _mgui_clear(self) -> None: + self._items.clear() + self._sync_options() + + +class MainWindow(Container, protocols.MainWindowProtocol): + def __init__(self, layout="vertical", scrollable: bool = False, **kwargs): + super().__init__(layout=layout, scrollable=scrollable, **kwargs) + # the box created by Container becomes the central widget of the + # main-window grid; container children keep flowing into it (self._box) + main_window = IpyMainWindow() + main_window[IpyMainWindow.IDX_CENTRAL_WIDGET] = self._ipywidget + self._ipywidget: IpyMainWindow = main_window + + def _mgui_create_menu_item( + self, + menu_name: str, + action_name: str, + callback: Callable | None = None, + shortcut: str | None = None, + ): + # deprecated pathway; use MainWindowWidget.menu_bar instead, which + # routes through the MenuBar/Menu widgets above + raise NotImplementedError( + "create_menu_item is not supported in the ipynb backend; " + "use the `menu_bar` property instead" + ) + + 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(None if widget is None else widget.native) + + def _mgui_set_menu_bar(self, widget: Widget | None) -> None: + self._ipywidget.set_menu_bar(None if widget is None else widget.native) def get_text_width(text): diff --git a/src/magicgui/backends/_qtpy/__init__.py b/src/magicgui/backends/_qtpy/__init__.py index df1299332..d8e5a7790 100644 --- a/src/magicgui/backends/_qtpy/__init__.py +++ b/src/magicgui/backends/_qtpy/__init__.py @@ -14,6 +14,8 @@ LineEdit, LiteralEvalLineEdit, MainWindow, + Menu, + MenuBar, Password, ProgressBar, PushButton, @@ -24,6 +26,7 @@ Select, Slider, SpinBox, + StatusBar, Table, TextEdit, TimeEdit, @@ -48,6 +51,8 @@ "LineEdit", "LiteralEvalLineEdit", "MainWindow", + "Menu", + "MenuBar", "Password", "ProgressBar", "PushButton", @@ -58,6 +63,7 @@ "Select", "Slider", "SpinBox", + "StatusBar", "Table", "TextEdit", "TimeEdit", diff --git a/src/magicgui/backends/_qtpy/widgets.py b/src/magicgui/backends/_qtpy/widgets.py index 8dfb940c9..0623aa299 100644 --- a/src/magicgui/backends/_qtpy/widgets.py +++ b/src/magicgui/backends/_qtpy/widgets.py @@ -27,14 +27,17 @@ ) from magicgui.types import FileDialogMode, Separator -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: from collections.abc import Iterable, Iterator, Sequence import numpy + from magicgui.widgets.protocols import Area + @contextmanager def _signals_blocked(obj: QtW.QWidget) -> Iterator[None]: @@ -68,10 +71,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) @@ -561,13 +571,98 @@ def _mgui_set_orientation(self, value) -> None: def _mgui_get_orientation(self) -> str: """Set orientation, return either 'horizontal' or 'vertical'.""" - if isinstance(self, QtW.QHBoxLayout): + if isinstance(self._layout, QtW.QHBoxLayout): return "horizontal" else: 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(Container, protocols.MainWindowProtocol): + _qwidget: QtW.QMainWindow + def __init__( self, layout="vertical", scrollable: bool = False, **kwargs: Any ) -> None: @@ -611,6 +706,62 @@ def _mgui_create_menu_item( 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) + + +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: @@ -1219,7 +1370,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: @@ -1235,7 +1386,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) @@ -1276,6 +1430,38 @@ 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` (negative positions append).""" + if position < 0: + self._qwidget.addWidget(widget.native) + else: + 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 @@ -1318,7 +1504,7 @@ def _mgui_set_orientation(self, value) -> None: def _mgui_get_orientation(self) -> str: """Set orientation, return either 'horizontal' or 'vertical'.""" - return "horizontal" if isinstance(self, QtW.QHBoxLayout) else "vertical" + return "horizontal" if isinstance(self._layout, QtW.QHBoxLayout) else "vertical" def _mgui_exec(self) -> Any: return self._qwidget.exec_() diff --git a/src/magicgui/widgets/__init__.py b/src/magicgui/widgets/__init__.py index 98b9ced76..70a36cd42 100644 --- a/src/magicgui/widgets/__init__.py +++ b/src/magicgui/widgets/__init__.py @@ -29,6 +29,8 @@ LiteralEvalLineEdit, LogSlider, MainWindow, + Menu, + MenuBar, Password, ProgressBar, PushButton, @@ -41,6 +43,7 @@ SliceEdit, Slider, SpinBox, + StatusBar, TextEdit, TimeEdit, ToolBar, @@ -92,6 +95,8 @@ "LogSlider", "MainFunctionGui", "MainWindow", + "Menu", + "MenuBar", "Password", "ProgressBar", "PushButton", @@ -104,6 +109,7 @@ "SliceEdit", "Slider", "SpinBox", + "StatusBar", "Table", "TextEdit", "TimeEdit", diff --git a/src/magicgui/widgets/_concrete.py b/src/magicgui/widgets/_concrete.py index f6847b629..b4bceede7 100644 --- a/src/magicgui/widgets/_concrete.py +++ b/src/magicgui/widgets/_concrete.py @@ -40,9 +40,12 @@ ContainerWidget, DialogWidget, MainWindowWidget, + MenuBarWidget, + MenuWidget, MultiValuedSliderWidget, RangedWidget, SliderWidget, + StatusBarWidget, ToolBarWidget, TransformedRangedWidget, ValuedContainerWidget, @@ -1001,6 +1004,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 4b809d55e..b524e4c27 100644 --- a/src/magicgui/widgets/bases/__init__.py +++ b/src/magicgui/widgets/bases/__init__.py @@ -45,12 +45,14 @@ def __init__( BaseContainerWidget, ContainerWidget, DialogWidget, - MainWindowWidget, ValuedContainerWidget, ) 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 BaseValueWidget, ValueWidget from ._widget import Widget @@ -63,9 +65,12 @@ def __init__( "ContainerWidget", "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 0e7421627..42eb8301d 100644 --- a/src/magicgui/widgets/bases/_container_widget.py +++ b/src/magicgui/widgets/bases/_container_widget.py @@ -490,25 +490,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..69b2b45fb --- /dev/null +++ b/src/magicgui/widgets/bases/_main_window.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, 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 menu 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 menu 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) diff --git a/src/magicgui/widgets/bases/_menubar.py b/src/magicgui/widgets/bases/_menubar.py new file mode 100644 index 000000000..7e9aadd5a --- /dev/null +++ b/src/magicgui/widgets/bases/_menubar.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, 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..06cf37859 --- /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): + """Status bar widget, wraps StatusBarProtocol. + + 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 end of the status bar.""" + self.insert_widget(-1, widget) + + def insert_widget(self, position: int, widget: Widget) -> None: + """Insert a widget at the given position (negative positions append).""" + self._widget._mgui_insert_widget(position, widget) + + def remove_widget(self, widget: Widget) -> None: + """Remove a widget from the status bar.""" + 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 c79641e44..ca3d42451 100644 --- a/src/magicgui/widgets/bases/_toolbar.py +++ b/src/magicgui/widgets/bases/_toolbar.py @@ -1,17 +1,13 @@ from __future__ import annotations from collections.abc import Callable -from typing import TYPE_CHECKING, Any, TypeVar, Union +from typing import TYPE_CHECKING, Any 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 5807b0d34..434402ad8 100644 --- a/src/magicgui/widgets/protocols.py +++ b/src/magicgui/widgets/protocols.py @@ -22,10 +22,13 @@ if TYPE_CHECKING: from collections.abc import Iterable, Sequence + from typing import Literal 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: @@ -551,6 +554,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.""" @@ -586,6 +692,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/conftest.py b/tests/conftest.py index ba60c7b31..7bc5d5fb2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,9 +35,31 @@ def always_qapp(qapp): @pytest.fixture(autouse=True, scope="function") -def _clean_return_callbacks(): +def _clean_type_map(): + """Undo any mutation of the global type map made during a test. + + `register_type` mutates the global `TypeMap` in place, and not every caller + undoes it -- example scripts run by `test_examples.py` register widget types + at import time (e.g. `matplotlib/waveform.py` maps `int` -> `Slider`), which + would otherwise change widget selection for every subsequent test. + """ from magicgui.type_map import TypeMap + type_map = TypeMap.global_instance() + mappings = ( + type_map._simple_types, + type_map._simple_annotations, + type_map._type_defs, + type_map._additional_kwargs, + ) + before = [dict(mapping) for mapping in mappings] + # values here are lists, which tests may append to in place + callbacks_before = {k: list(v) for k, v in type_map._return_callbacks.items()} + yield - TypeMap.global_instance()._return_callbacks.clear() + for mapping, snapshot in zip(mappings, before, strict=False): + mapping.clear() + mapping.update(snapshot) + type_map._return_callbacks.clear() + type_map._return_callbacks.update(callbacks_before) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index edcdb4abc..4866453b3 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -436,39 +436,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) diff --git a/tests/test_window.py b/tests/test_window.py new file mode 100644 index 000000000..bfa361691 --- /dev/null +++ b/tests/test_window.py @@ -0,0 +1,154 @@ +import importlib.util + +import pytest + +from magicgui import magicgui, use_app, widgets + +params = ["qt"] +if importlib.util.find_spec("ipywidgets"): + params.insert(0, "ipynb") + + +# it's important that "qt" be last here, so that it's used for +# the rest of the tests +@pytest.fixture(scope="module", params=params) +def backend(request): + return request.param + + +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_main_window_central_widget(backend): + """The MainWindow is a Container; children go into the central widget.""" + use_app(backend) + main = widgets.MainWindow() + button = widgets.PushButton(text="central") + main.append(button) + assert len(main) == 1 + assert main[0] is button + label = widgets.Label(value="also central") + main.insert(0, label) + assert len(main) == 2 + assert main[0] is label + main.remove(label) + assert len(main) == 1 + main.close() + + +def test_main_window_dock_and_tool_bars(backend): + use_app(backend) + main = widgets.MainWindow() + for area in ("left", "right", "top", "bottom"): + main.add_dock_widget(widgets.Label(value=area), area=area) + + tool_bar = widgets.ToolBar() + tool_bar.add_button(text="Folder", icon="folder") + tool_bar.add_spacer() + main.add_tool_bar(tool_bar, area="top") + main.close() + + +def test_main_window_tool_bar_type_error(): + use_app("qt") + main = widgets.MainWindow() + with pytest.raises(TypeError): + main.add_tool_bar(widgets.Label(value="not a toolbar")) + main.close() + + +def test_main_window_status_bar(backend): + use_app(backend) + main = widgets.MainWindow() + status_bar = main.status_bar # lazily created and attached + assert status_bar is main.status_bar + status_bar.set_message("Hello Status!") + assert status_bar.message == "Hello Status!" + status_bar.message = "" + assert not status_bar.message + + label = widgets.Label(value="perm") + status_bar.add_widget(label) + status_bar.remove_widget(label) + + main.status_bar = None + main.close() + + +def test_main_window_menus(backend): + use_app(backend) + main = widgets.MainWindow() + fired = [] + + file_menu = main.menu_bar.add_menu("File") + assert file_menu is main.menu_bar["File"] + assert file_menu.title == "File" + file_menu.add_action("Open", callback=lambda: fired.append("open")) + file_menu.add_separator() + file_menu.add_action("Close", callback=lambda: fired.append("close")) + + # trigger the "Open" action the way the frontend would + if backend == "qt": + action = next(a for a in file_menu.native.actions() if a.text() == "Open") + action.trigger() + else: + dropdown = file_menu.native + value = next(v for label, v in dropdown.options if label == "Open") + dropdown.value = value + # selection resets to the title placeholder after triggering + assert dropdown.value == file_menu._widget._TITLE + assert fired == ["open"] + + if backend == "qt": + submenu = file_menu.add_menu("Submenu") + submenu.add_action("Subaction", callback=lambda: fired.append("sub")) + else: + with pytest.raises(NotImplementedError): + file_menu.add_menu("Submenu") + + file_menu.clear() + main.menu_bar.clear() + main.menu_bar = None + main.close() + + +def test_main_window_create_menu_item(backend): + """The legacy create_menu_item pathway.""" + use_app(backend) + main = widgets.MainWindow() + if backend == "qt": + main.create_menu_item("Help", "About", callback=lambda: None) + else: + with pytest.raises(NotImplementedError): + main.create_menu_item("Help", "About") + main.close()