Repository: marimo-team/marimo
Pull request: improvement: mo.ui.spinner can display without a context manager
Fixes #1271

Add a `_mime_()` to the spinner class to display without a context manager

Problem statement:
Possibility of placing "Spinner" element anywhere on UI, similar to other UI components (Buttons, forms etc.)
### Description

I'm looking for a way to place a spinner element that pops up upon some user interaction, to show the status of a long running task (calculation). As I work with UI elements, like buttons, forms, batch etc, I often stack these together using vstack or hstack, to order elements neatly as larger components. I would like that to be the case for a "Spinner" element as well. Currently, spinners are used as context managers, and are destroyed (unless "remove_on_exit" is set False), once the context manager exits. 


### Suggested solution

When a cell contains a spinner element, render that cell as a UI element, and as a placeholder put a, non-animated, empty spinner. The spinner may be triggered similarly to other elements as part of the reactivity of marimo.

### Alternative

Currently, a spinner element can be dragged in the app builder to a desired location, but it does not allow for stacking, and overlaying with other elements is difficult. Additionally, the spinner only renders if the cell in which it is defined is triggered. Otherwise python code is simply shown instead (at least in edit mode).

### Additional context

_No response_

Hints from issue discussion:
I have a fix out for this: https://github.com/marimo-team/marimo/pull/1278

Here is an example for usage:

```python
import marimo

__generated_with = "0.4.7"
app = marimo.App()


@app.cell
def __():
    import marimo as mo
    return mo,


@app.cell
def __(mo):
    counter_button = mo.ui.button(
        value=0, on_click=lambda value: value + 1, label="increment"
    )
    counter_button
    return counter_button,


@app.cell
def __(counter_button, mo):
    mo.vstack([
        counter_button.value,
        mo.status.spinner(remove_on_exit=False) if counter_button.value < 3 else mo.md("Done!"),
    ])
    return


if __name__ == "__main__":
    app.run()
```

Commit message: pycheck

Patch to review:
diff --git a/marimo/_plugins/stateless/status/_progress.py b/marimo/_plugins/stateless/status/_progress.py
index 782063000dd..3276dd38441 100644
--- a/marimo/_plugins/stateless/status/_progress.py
+++ b/marimo/_plugins/stateless/status/_progress.py
@@ -1,18 +1,18 @@
 # Copyright 2024 Marimo. All rights reserved.
 from __future__ import annotations
 
-import contextlib
 import time
 from typing import (
     TYPE_CHECKING,
+    Any,
     Callable,
     Iterable,
-    Iterator,
     Optional,
     TypeVar,
 )
 
 import marimo._runtime.output._output as output
+from marimo._messaging.mimetypes import KnownMimeType
 from marimo._output.hypertext import Html
 from marimo._output.rich_help import mddoc
 from marimo._plugins.core.web_component import build_stateless_plugin
@@ -217,12 +217,7 @@ def update(
 
 
 @mddoc
-@contextlib.contextmanager
-def spinner(
-    title: Optional[str] = None,
-    subtitle: Optional[str] = None,
-    remove_on_exit: bool = True,
-) -> Iterator[Spinner]:
+class spinner:
     """Show a loading spinner
 
     Use `mo.status.spinner()` as a context manager to show a loading spinner.
@@ -239,21 +234,49 @@ def spinner(
     mo.ui.table(data)
     ```
 
+    You can also show the spinner without a context manager:
+
+    ```python
+    mo.hstack(
+        [
+            mo.status.spinner(title="Loading ...")
+            if condition
+            else mo.md("Done!")
+        ]
+    )
+    ```
+
     **Args:**
 
     - `title`: optional title
     - `subtitle`: optional subtitle
     - `remove_on_exit`: if True, the spinner is removed from output on exit
     """
-    spinner = Spinner(title=title, subtitle=subtitle)
-    output.append(spinner)
-    try:
-        yield spinner
-    finally:
-        if remove_on_exit:
-            spinner.clear()
+
+    def __init__(
+        self,
+        title: Optional[str] = None,
+        subtitle: Optional[str] = None,
+        remove_on_exit: bool = True,
+    ):
+        self.title = title
+        self.subtitle = subtitle
+        self.remove_on_exit = remove_on_exit
+        self.spinner = Spinner(title=self.title, subtitle=self.subtitle)
+        # output.append(self.spinner)
+
+    def __enter__(self) -> Spinner:
+        output.append(self.spinner)
+        return self.spinner
+
+    def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
+        if self.remove_on_exit:
+            self.spinner.clear()
         # TODO(akshayka): else consider transitioning to a done state
-        spinner.close()
+        self.spinner.close()
+
+    def _mime_(self) -> tuple[KnownMimeType, str]:
+        return self.spinner._mime_()
 
 
 def progress_bar(
diff --git a/marimo/_smoke_tests/bugs/1271.py b/marimo/_smoke_tests/bugs/1271.py
new file mode 100644
index 00000000000..b5282183484
--- /dev/null
+++ b/marimo/_smoke_tests/bugs/1271.py
@@ -0,0 +1,40 @@
+# Copyright 2024 Marimo. All rights reserved.
+import marimo
+
+__generated_with = "0.4.7"
+app = marimo.App()
+
+
+@app.cell
+def __():
+    import marimo as mo
+    return mo,
+
+
+@app.cell
+def __(mo):
+    with mo.status.spinner(remove_on_exit=False):
+        pass
+    return
+
+
+@app.cell
+def __(mo):
+    counter_button = mo.ui.button(
+        value=0, on_click=lambda value: value + 1, label="increment"
+    )
+    counter_button
+    return counter_button,
+
+
+@app.cell
+def __(counter_button, mo):
+    mo.vstack([
+        counter_button.value,
+        mo.status.spinner(remove_on_exit=False) if counter_button.value < 3 else mo.md("Done!"),
+    ])
+    return
+
+
+if __name__ == "__main__":
+    app.run()
diff --git a/tests/_ast/test_codegen.py b/tests/_ast/test_codegen.py
index 5b646d83c5f..1a2269077a1 100644
--- a/tests/_ast/test_codegen.py
+++ b/tests/_ast/test_codegen.py
@@ -48,7 +48,9 @@ def wrap_generate_filecontents(
     cell_configs: Optional[list[CellConfig]] = None,
     config: Optional[_AppConfig] = None,
 ) -> str:
-    """Wraps codegen.generate_filecontents to make the cell_configs argument optional."""
+    """
+    Wraps codegen.generate_filecontents to make the
+    cell_configs argument optional."""
     if cell_configs is None:
         resolved_configs = [CellConfig() for _ in range(len(codes))]
     else:


Code review comments:
diff_hunk: @@ -239,21 +234,49 @@ def spinner(
     mo.ui.table(data)
     ```
 
+    You can also show the spinner without a context manager:
+
+    ```python
+    mo.hstack(
+        [
+            mo.status.spinner(title="Loading ...")
+            if condition
+            else mo.md("Done!")
+        ]
+    )
+    ```
+
     **Args:**
 
     - `title`: optional title
     - `subtitle`: optional subtitle
     - `remove_on_exit`: if True, the spinner is removed from output on exit
     """
-    spinner = Spinner(title=title, subtitle=subtitle)
-    output.append(spinner)
-    try:
-        yield spinner
-    finally:
-        if remove_on_exit:
-            spinner.clear()
+
+    def __init__(
+        self,
+        title: Optional[str] = None,
+        subtitle: Optional[str] = None,
+        remove_on_exit: bool = True,
+    ):
+        self.title = title
+        self.subtitle = subtitle
+        self.remove_on_exit = remove_on_exit
+        self.spinner = Spinner(title=self.title, subtitle=self.subtitle)
+        # output.append(self.spinner)
path: marimo/_plugins/stateless/status/_progress.py
text: @user1:
delete this line of code?