MEP | Title | Discussion | Implementation |
---|---|---|---|
3 |
UI elements |
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.
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.
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
A UI element is an HTML element that has a value. Its value may change when interacted with.
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 UIElement
s 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.
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 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.
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 UIElement
s.
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 UIElement
s 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
.
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-element
s 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.
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.
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.
- 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.
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.
- An API for registering custom UI elements at runtime.
- An API for serializing the state of all UI elements to disk and loading serialized state from disk into a marimo session.