diff --git a/pyproject.toml b/pyproject.toml index 08bac516f..35b3a8644 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ pyside2 = [ pyside6 = ["pyside6"] tqdm = ["tqdm>=4.30.0"] jupyter = ["ipywidgets>=8.0.0"] +textual = ["textual"] # TODO: figure min version image = ["pillow>=4.0"] quantity = ["pint>=0.13.0"] testing = [ diff --git a/src/magicgui/application.py b/src/magicgui/application.py index 2792ad824..dd7771086 100644 --- a/src/magicgui/application.py +++ b/src/magicgui/application.py @@ -70,9 +70,9 @@ def get_obj(self, name: str) -> Any: f"Could not import object {name!r} from backend {self.backend_module}" ) from e - def run(self) -> None: + def run(self, **kwargs: Any) -> None: """Enter the native GUI event loop.""" - return self._backend._mgui_run() + return self._backend._mgui_run(**kwargs) @property def native(self) -> Any: diff --git a/src/magicgui/backends/__init__.py b/src/magicgui/backends/__init__.py index 9470e2924..24ac3c88c 100644 --- a/src/magicgui/backends/__init__.py +++ b/src/magicgui/backends/__init__.py @@ -5,6 +5,7 @@ BACKENDS: dict[str, tuple[str, str]] = { "Qt": ("_qtpy", "qtpy"), "ipynb": ("_ipynb", "ipynb"), + "textual": ("_textual", "textual"), } for key in list(BACKENDS): diff --git a/src/magicgui/backends/_textual/__init__.py b/src/magicgui/backends/_textual/__init__.py new file mode 100644 index 000000000..9a87f7416 --- /dev/null +++ b/src/magicgui/backends/_textual/__init__.py @@ -0,0 +1,4 @@ +from .application import ApplicationBackend +from .widgets import CheckBox, Label, LineEdit, PushButton + +__all__ = ["ApplicationBackend", "Label", "LineEdit", "PushButton", "CheckBox"] diff --git a/src/magicgui/backends/_textual/application.py b/src/magicgui/backends/_textual/application.py new file mode 100644 index 000000000..7e198de66 --- /dev/null +++ b/src/magicgui/backends/_textual/application.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable, ClassVar, Iterable + +from textual.app import App +from textual.binding import Binding +from textual.timer import Timer +from textual.widgets import Footer + +from magicgui.widgets.protocols import BaseApplicationBackend + +if TYPE_CHECKING: + from textual.message import Message + from textual.widget import Widget + + +class MguiApp(App): + BINDINGS = [ + ("ctrl+t", "app.toggle_dark", "Toggle Dark mode"), + ("ctrl+s", "app.screenshot()", "Screenshot"), + Binding("ctrl+c,ctrl+q", "app.quit", "Quit", show=True), + ] + + HEADER = None + FOOTER = Footer() + + _mgui_widgets: ClassVar[list[Widget]] = [] + + def compose(self) -> Iterable[Widget]: + if self.HEADER is not None: + yield self.HEADER + yield from self._mgui_widgets + yield self.FOOTER + + +class ApplicationBackend(BaseApplicationBackend): + _app: ClassVar[MguiApp | None] = None + + @classmethod + def _instance(cls) -> MguiApp: + """Return the current instance of the application backend.""" + if not hasattr(cls, "__instance"): + cls.__instance = MguiApp() + return cls.__instance + + def _mgui_get_backend_name(self) -> str: + return "textual" + + def _mgui_process_events(self) -> None: ... + + def _mgui_run(self, **kwargs) -> None: + self._mgui_get_native_app().run(**kwargs) + + def _mgui_quit(self) -> None: + return self._mgui_get_native_app().exit() + + def _mgui_get_native_app(self) -> App: + # Get native app + return self._instance() + + def _mgui_start_timer( + self, + interval: int = 0, + on_timeout: Callable[[], None] | None = None, + single: bool = False, + ) -> None: + # TODO: not sure what to do with these yet... + event_target = MessageTarget() + sender = MessageTarget() + self._timer = Timer( + event_target=event_target, + interval=interval / 1000, + sender=sender, + callback=on_timeout, + repeat=1 if single else None, + ) + self._timer.start() + + def _mgui_stop_timer(self) -> None: + if getattr(self, "_timer", None): + self._timer.stop() + + +class MessageTarget: + async def post_message(self, message: Message) -> bool: ... + + async def _post_priority_message(self, message: Message) -> bool: ... + + def post_message_no_wait(self, message: Message) -> bool: ... diff --git a/src/magicgui/backends/_textual/widgets.py b/src/magicgui/backends/_textual/widgets.py new file mode 100644 index 000000000..973bd793b --- /dev/null +++ b/src/magicgui/backends/_textual/widgets.py @@ -0,0 +1,253 @@ +from typing import TYPE_CHECKING, Any, Callable, cast + +from textual import widgets as txtwdgs +from textual.widget import Widget as TxWidget + +from magicgui.widgets import protocols + +from .application import MguiApp + +try: + # useful, but not yet public... + from psygnal._weak_callback import WeakCallback, weak_callback + +except ImportError: + WeakCallback = Callable[[Any], Any] + + def weak_callback(callback: Callable[[Any], Any]) -> WeakCallback: + return callback + + +if TYPE_CHECKING: + import numpy as np + from textual.dom import DOMNode + + +# Convert class events to instance events... +# FIXME: there must be a better pattern, also need weakrefs +class _Button(txtwdgs.Button): + _callbacks: list[WeakCallback] = [] + + def on_button_pressed(self): + for callback in self._callbacks: + callback(True) + + +class _Input(txtwdgs.Input): + _callbacks: list[WeakCallback] = [] + + def on_input_changed(self): + for callback in self._callbacks: + callback(self.value) + + +class _Switch(txtwdgs.Switch): + _callbacks: list[WeakCallback] = [] + + def on_switch_changed(self): + for callback in self._callbacks: + callback(self.value) + + +class TxtBaseWidget(protocols.WidgetProtocol): + """Base Widget Protocol: specifies methods that all widgets must provide.""" + + _txwidget: TxWidget + + def __init__( + self, wdg_class: type[TxWidget] | None = None, parent: TxWidget | None = None + ): + if wdg_class is None: + wdg_class = type(self).__annotations__.get("_txwidget") + if wdg_class is None: + raise TypeError("Must provide a valid textual widget type") + self._txwidget = wdg_class() + + # TODO: here we add the widget to our global app instance... but perhaps + # we should be using `mount()`? + MguiApp._mgui_widgets.append(self._txwidget) + + # TODO: assign parent ? + + def _mgui_close_widget(self) -> None: + """Close widget.""" + # not sure there is a textual equivalent of closing? + self._mgui_set_visible(False) + + def _mgui_get_visible(self) -> bool: + """Get widget visibility.""" + return self._txwidget.visible + + def _mgui_set_visible(self, value: bool) -> None: + """Set widget visibility.""" + self._txwidget.visible = value + + def _mgui_get_enabled(self) -> bool: + """Get the enabled state of the widget.""" + return not self._txwidget.disabled + + def _mgui_set_enabled(self, enabled: bool) -> None: + """Set the enabled state of the widget to `enabled`.""" + self._txwidget.disabled = not enabled + + def _mgui_get_parent(self) -> "DOMNode | None": + """Return the parent widget of this widget.""" + return self._txwidget.parent + + def _mgui_set_parent(self, widget: TxWidget) -> None: + """Set the parent widget of this widget.""" + raise NotImplementedError("Setting parent of textual widget not supported") + + def _mgui_get_native_widget(self) -> Any: + """Return the native backend widget instance. + + This is generally the widget that has the layout. + """ + return self._txwidget + + def _mgui_get_root_native_widget(self) -> Any: + """Return the root native backend widget. + + In most cases, this is the same as ``_mgui_get_native_widget``. However, in + cases where the native widget is in a scroll layout, this might be different. + """ + return self._txwidget + + def _mgui_bind_parent_change_callback( + self, callback: Callable[[Any], None] + ) -> None: + """Bind callback to parent change event.""" + pass + + def _mgui_render(self) -> "np.ndarray": + """Return an RGBA (MxNx4) numpy array bitmap of the rendered widget.""" + raise NotImplementedError("Textual widget screenshots not yet implemented") + + def _mgui_get_width(self) -> int: + """Get the width of the widget. + + The intention is to get the width of the widget after it is shown, for the + purpose of unifying widget width in a layout. Backends may do what they need to + accomplish this. For example, Qt can use ``sizeHint().width()``, since + ``width()`` may return something large if the widget has not yet been painted + on screen. + """ + return self._txwidget.styles.width + + def _mgui_set_width(self, value: int) -> None: + """Set the width of the widget.""" + self._txwidget.styles.width = value + + def _mgui_get_min_width(self) -> int: + """Get the minimum width of the widget.""" + return self._txwidget.styles.min_width + + def _mgui_set_min_width(self, value: int) -> None: + """Set the minimum width of the widget.""" + self._txwidget.styles.min_width = value + + def _mgui_get_max_width(self) -> int: + """Get the maximum width of the widget.""" + return self._txwidget.styles.max_width + + def _mgui_set_max_width(self, value: int) -> None: + """Set the maximum width of the widget.""" + self._txwidget.styles.max_width = value + + def _mgui_get_height(self) -> int: + """Get the height of the widget. + + The intention is to get the height of the widget after it is shown, for the + purpose of unifying widget height in a layout. Backends may do what they need to + accomplish this. For example, Qt can use ``sizeHint().height()``, since + ``height()`` may return something large if the widget has not yet been painted + on screen. + """ + return self._txwidget.styles.height + + def _mgui_set_height(self, value: int) -> None: + """Set the height of the widget.""" + self._txwidget.styles.height = value + + def _mgui_get_min_height(self) -> int: + """Get the minimum height of the widget.""" + return self._txwidget.styles.min_height + + def _mgui_set_min_height(self, value: int) -> None: + """Set the minimum height of the widget.""" + self._txwidget.styles.min_height = value + + def _mgui_get_max_height(self) -> int: + """Get the maximum height of the widget.""" + return self._txwidget.styles.max_height + + def _mgui_set_max_height(self, value: int) -> None: + """Set the maximum height of the widget.""" + self._txwidget.styles.max_height = value + + def _mgui_get_tooltip(self) -> str: + """Get the tooltip for this widget.""" + return "" + + def _mgui_set_tooltip(self, value: str | None) -> None: + """Set a tooltip for this widget.""" + pass + + +class TxtValueWidget(TxtBaseWidget, protocols.ValueWidgetProtocol): + _txwidget: txtwdgs.Static + + def _mgui_get_value(self) -> Any: + """Get current value of the widget.""" + return self._txwidget.renderable + + def _mgui_set_value(self, value: Any) -> None: + """Set current value of the widget.""" + self._txwidget.renderable = value + + def _mgui_bind_change_callback(self, callback: Callable[[Any], Any]) -> None: + """Bind callback to value change event.""" + if hasattr(self._txwidget, "_callbacks"): + callbacks = cast("list", self._txwidget._callbacks) + callbacks.append(weak_callback(callback)) + + +class TxtStringWidget(TxtValueWidget): + def _mgui_set_value(self, value) -> None: + super()._mgui_set_value(str(value)) + + +class Label(TxtStringWidget): + _txwidget: txtwdgs.Label + + +class LineEdit(TxtStringWidget): + _txwidget: _Input + + def _mgui_get_value(self) -> Any: + """Get current value of the widget.""" + return self._txwidget.value + + def _mgui_set_value(self, value: Any) -> None: + """Set current value of the widget.""" + self._txwidget.value = value + + +class TxtBaseButtonWidget(TxtValueWidget, protocols.SupportsText): + _txwidget: _Button + + def _mgui_set_text(self, value: str) -> None: + """Set text.""" + self._txwidget.label = value + + def _mgui_get_text(self) -> str: + """Get text.""" + return self._txwidget.label + + +class PushButton(TxtBaseButtonWidget): + pass + + +class CheckBox(TxtBaseButtonWidget): + _txwidget: _Switch diff --git a/src/magicgui/widgets/protocols.py b/src/magicgui/widgets/protocols.py index 348d5be5d..cff061cbb 100644 --- a/src/magicgui/widgets/protocols.py +++ b/src/magicgui/widgets/protocols.py @@ -630,7 +630,7 @@ def _mgui_start_timer( Parameters ---------- interval : int, optional - Interval between timeouts, by default 0 + Interval (in msec) between timeouts, by default 0 on_timeout : Optional[Callable[[], None]], optional Function to call when timer finishes, by default None single : bool, optional