Skip to content

Latest commit

 

History

History
511 lines (392 loc) · 17.3 KB

mep-0003.md

File metadata and controls

511 lines (392 loc) · 17.3 KB
MEP Title Discussion Implementation
3
UI elements

UI Elements

Abstract

This MEP lays out a design for creating user interface (UI) elements as cell outputs, synchronizing their values on the frontend and with the kernel, and determining which cells are run when an element's value changes.

Motivation

Marimo provides a pure Python experience for building experiments and applications using an extensible, modular, and composable programming framework. UI elements are key to enabling both rapid experimentation and application development.

Criteria

We strive for a design that is:

  • seamless: accessing a UI element's value must be seamless; no callbacks
  • simple: a simple rule should explain what cells are run when a UI element is interacted with
  • Pythonic: creating, displaying, and reading UI elements should be Pythonic
  • no web programming: 99% of users shouldn't need to write any HTML, Javascript, or CSS
  • customizable: styling of UI elements should be customizable with CSS
  • extensible: it should be easy for developers to implement custom UI elements, using any web technology

UI elements: HTML objects with values

A UI element is an HTML element that has a value. Its value may change when interacted with.

The UIElement type

A UIElement is a Python class representing a UI Element. Its value can be set at creation time by the user, to designate a default value for the element. After creation, the value cannot be changed by user code.

The frontend can render the same UIElement multiple times, with multiple instances of the same element tied by a unique key (generated by marimo on the user's behalf). These rendered elements may choose to update their values at any time, but typically their values will only change upon user interaction.

Frontend synchronization. When the value of a rendered UIElement changes, all other instances of the element that are rendered have their values updated as well.

Kernel synchronization. When a rendered UIElements value changes, the frontend sends a value update request to the kernel. The kernel is then responsible for updating the value of the Python object corresponding to the element. When this happens, the kernel wil run certain cells that depend on the UIElement object, enbling user interaction to drive execution of other cells. The rule for which cells are run is described in a later section.

Element library

The marimo library will ship with a library of pre-fabricated UI elements. These elements will be made available through the module marimo.ui. Some examples include a numeric slider, number picker, text box, checkbox, radio button and radio group, drop-down menu, file upload box, and button. Additional elements may include elements that build a composite UI element given a template of constituent elements; for example, elements that take list and dict templates (with list and dict values), and an element that takes an HTML template.

We defer the design of marimo.ui to a future MEP.

A runtime rule

A single rule determines which cells will run after a UIElement is interacted with: any cell referring to one or more of the names bound to a UIElement will be run whenever a UI element sends a value to the kernel. By "referring", we mean that the cell has a name bound to the element among its refs.

Examples

A simple example.

cell a

text = mo.ui.text()
text

cell b

text.value

cell c

str(text)

Interacting with the UI element in cell a will cause cells b and c to run, since text is a name bound to the element, and cells b and c both have text among its refs. Cell a will not run, because it has text among its defs, not its refs.

Two bound names.

cell a

text = mo.ui.text()
text_alias = text
text

cell b

text.value

cell c

text_alias.value

Interacting with element in a will cause cells b and c to run, since text and text_alias are names bound to the same UIElement object.

Constructor cells.

An interaction will never cause a cell that creates the interacted-with UIElement to run, since the constructing cell will never have the element's name(s) among its refs.

This is an intended consequence of the ruleset, for if a cell that created the UIElement were run upon interaction, then the UIElement would be reconstructed and re-initialized with its default value, which would undo the effect of interacting with the element, making interaction pointless.

In some cases, the fact that constructor cells are not run may be surprising. For example:

cell a

# this cell won't rerun on interaction
text = mo.ui.text()
contents = text.value
text

cell b

contents

Interacting with the text in a will not update the value of contents, and will not cause b to run. To prevent confusion, we raise an exception at runtime when a UIElement's value is accessed in the cell that created it. Here, line 2 of cell a would raise an exception.

Cells that reference and output a UI element.

Let element be a UIElement in the cell

cell a

element

Interacting with its output will trigger execution of a. In most cases this execution will be unneeded, but harmless, since cells should be idempotent; performance optimizations in the kernel and frontend can lessen the wasted computation (for example, we may choose to only send outputs to the frontend when the output has changed).

In other cases, re-execution of the cell is in fact intended, as in cell b in the below example:

cell a

text = mo.ui.text()

cell b:

contents = text.value
text, f"character count: {len(contents)}"

Unnamed UIElements.

If a UIElement does not have a name, then interacting with it will not trigger execution of other cells, no matter where the element is used.

For example:

cell a

l = [mo.ui.text(), mo.ui.number()]
l

cell b

l[0].value

Interacting with l's stored text element in cell a will not cause cell b to run, because the text element is unnamed: l[0] is not a name bound to a UIElement object, instead it is an expression that evaluates to a UIElement at runtime.

Traversing pointers? One might consider traversing pointers (via gc.get_referrers()) to discover where unnamed UIElements are used, and run those cells as well; however, this may lead to what appears to be "action at a distance" when a UIElement is heavily nested or hidden within the internals of some object. Moreover, it is possible for gc.get_referrers() to miss some objects, if those objects don't support garbage collection, though in practice this will rarely happen.

Given the extra complexity that traversing pointers would add to the ruleset and implementation, we have decided against this path.

Composite elements from templates. We will provide library functions that allow users to build a UI element given a template of constituent UI elements. For example, the previous example involving a list could be rewritten as

cell a

l = mo.ui.array(template=[mo.ui.text(), mo.ui.number())])
l

cell b

l.value[0]

The element l is a single UI element built from a template containing a text element and a number element, and its output embeds the output of a text and number element. The value of l is a list with entries equal to the values its constituent elements.

Interacting with l would now execute b, as desired.

We say that l is built from a template because its embedded elements are clones of the passed in elements, not the passed in elements itself. To make this clear, consider

cell a

t = mo.ui.text()
l = mo.ui.array(template=[t])
l

cell b

t

Interacting with l in a will not run b, since b does not reference l. In particular, interacting with l does not generate an interaction with t, since t is not embedded in l; instead, an unnamed clone of t is in l.

Frontend Implementation

The marimo-ui-element tag

We define a custom HTML tag, <marimo-ui-element>, for rendering and sychronizing UI elements on the page and with the kernel; this tag is registered as a custom element. A <marimo-ui-element> wraps a single container element at which the UI element is rooted.

Object ID. A marimo-ui-element has a single attribute, object-id, which uniquely identifies a UIElement object in the Python kernel. There may be multiple <marimo-ui-element> tags on the page with the same object id if the same (Python) element is shown multiple times on the page.

Communication. The marimo-ui-element's child sends a custom event, marimo-value-input, when its value has been updated:

type marimoValueInputEventType = CustomEvent<{ value: any }>;

In response, the marimo-ui-element component sends a marimo-value-update event to all other marimo-ui-elements sharing its object id to inform them that their values need to be updated:

type marimoValueUpdateEventType = CustomEvent<{ value: any }>;

It also sends a message to the kernel, informing it to send a value update request to the Python kernel. The value is encoded as JSON before it is sent.

UI elements as custom elements

We implement the elements provided in marimo.ui as custom elements (e.g., <marimo-slider start="1" stop="10" step="2"></marimo-slider>) that render their content in a shadow DOM. This has the following advantages:

  • styles are isolated from the editor
  • the implementation of the UI element is separate from the editor code and is framework agnostic
  • UI elements can be composed using slots
  • UI elements are easy to create from Python (just an HTML tag)
  • using web components provides a simple path for others to implement their own UI elements and register them with marimo

Third-party elements. Users should be able to implement their own elements by creating their own custom web components, and using marimo's event model to publish, and subscribe to, value updates. In the future, we will provide a mechanism for registering third-party elements at runtime.

Care must be taken to avoid name collisions of custom elements. One way to do this would be to automatically append the third party's package name as a prefix to the custom element name.

UI elements are not sandboxed. We note that UI elements are not isolated from the main document: they may read and modify anything on the page, though in the vast majority of cases it would be good practice for them to not do so. This is a conscious choice, since the alternatives are either too restrictive (such as rendering all UI elements in sandboxed iframes) or cumbersome (serving UI elements from different origins). Ultimately, marimo embraces end-user programming, and we don't want to impose undue limitations on what users can do.

Just as users must judge whether a Python library is safe for them to use, they must also make sure to only use UI elements that they trust. If the user trusts marimo, then they can trust the elements in marimo.ui; more care must be taken when using third-party elements. Additionally, care must be taken when deploying marimo apps to ensure that CSRF attack vectors aren't present.

Python Implementation

The Python UIElement class

We introduce a UIElement class that wraps HTML text in a <marimo-ui-element> tag and registers the element with the kernel. This class implements the formatting.MIME protocol, and its __mime__ method returns an output of type text/html. Its value is made available through a property called value.

To create a UIElement, one subclasses this class, builds the HTML string from constructor parameters, and optionally implements a _set_value method that sets the value of the element given the result of parsing the JSON value update. This API is not yet public.

For example, here is a prototype implementation of a text element:

class text(UIElement):
    def __init__(self, value: str = "", label: str = "") -> None:
        super().__init__(
            text=(
                f'<marimo-text data-initial-value="{value}" data-label="{label}">'
                '</marimo-text>'
            ),
            initial_value=value,
        )

And a more interesting implementation of a button element that optionally executes an action on click :

class button(UIElement):
    @staticmethod
    def _on_click_noop() -> None:
        return None

    def __init__(
        self,
        on_click: Callable[[], Any] = _on_click_noop,
        value: Optional[Any] = None,
        label: str = "click",
    ) -> None:
        self._on_click = on_click
        super().__init__(
            build_web_component(
                component_name="marimo-button",
                initial_value=None,
                args={"label": label},
            ),
            None,
        )

    def _set_value(self, value: Any) -> None:
        del value
        self._value = self._on_click()

Registering custom elements. In a future MEP, we will introduce a mechanism for registering UI elements that are implemented entirely outside Python. Once registered, these elements will be able to integrated with Python by subclassing the UIElement class, just like native marimo.ui elements.

Evaluation

  • seamless: accessing a UI element's value must be seamless; no callbacks
  • an element's value is accessible through its value attribute
  • simple: a simple rule should explain what cells are run when a UI element is interacted with
  • one sentence: cells that ref the element's names are run
  • Pythonic: creating, displaying, and reading UI elements should be Pythonic
  • creating is just a function call, displaying is an output, reading a property read
  • no web programming: 99% of users shouldn't need to write any HTML, Javascript, or CSS
  • marimo.ui provides premade elements
  • customizable: styling of UI elements should be customizable with CSS
  • web components styles are isolated from the editor; an element's Python constructor could take custom styles to pass on to the frontend.
  • extensible: it should be easy for developers to implement custom UI elements, using web technologies of their choice
  • web components provide a simple path for developers to integrate arbitrary components, and they won't need to include marimo libraries on the frontend, since all they'll need to do is fire a custom event on interaction and react to a custom event on value updates.

Alternatives considered

Kernel execution triggered on elem.value references.

We considered triggering execution based on whether a cell uses the value attribute of a named UIElement, instead of just the unqualified name. While this would prevent some unneccessary re-runs, it is harder to explain, doesn't fit with marimo's refs and defs model because attributes aren't tracked, and is incompatible with pointer traversal.

UIElement's name is a ref UIElement's value attribute is used
Reruns the interacted with cell Does not rerun the interacted with cell
Runs all cells that have name as a ref, even if they don't access its value Only runs cell if they access element's value
Compatible with accessing UIElement's value without referencing the `value` attr, such as via a cast or operator overload Incompatible with accessing UIElement's value without referencing the `value` attr
Compatible with pointer traversal to discover unnamed UI elements Incompatible with pointer traversal to discover unnamed UI elements
Easier to explain Easy to explain

Never re-running the cell whose output was interacted with. We considered not re-running the cell whose output is interacted with, to prevent unnecessary re-runs or surprising behavior. However, this complicates the runtime rule and prevents some legitimate use cases that require the interacted-with cell to run.

Manually binding names to UI elements. We considered separating UI element objects and their values into two separate entities, and providing an API to bind objects to names at runtime. An early prototype showed this to be very unwieldy as it often required naming both the element and its value, like input_ui_element and input_value. The syntax was also un-Pythonic, intimidating (using magical objects like mo.name()) or error-prone (using strings for variable names), and difficult to explain.

Rendering UI elements in iframes. We considered rendering UI elements elements as entire web pages in iframes, instead of as document fragments in shadow DOMs. The only benefit we saw for this was tighter sandboxing, but we ultimately decided sandboxing was not necessary (see discussion in previous section). The downsides were making messaging, resizing logic, and instantantiation from Python all more complicated.

Future work

  1. An API for registering custom UI elements at runtime.
  2. An API for serializing the state of all UI elements to disk and loading serialized state from disk into a marimo session.