Scriptable UI
Goals:
- Embedded scripting language
- Declarative/reactive UI
Requirements:
- Rust as the script host
- Good cross-platform story
Non-requirements:
- Performance?
2026-03-07
Summary so far
I spun up this notes page today, so I’ll summarize the motivation:
For future projects, we’ve been thinking about extensibility (related reading: Malleable software). In addition to making application’s data structures extensible, we also need extensible ways to display and interact with these extensions. For example, maybe in a music player app you’ve added a rating field to songs. We think custom properties deserve to be more than lists of plaintext key-value pairs. Maybe you’d like to render this rating as a number of stars. Instead of needing to build this option into the application, what if you could script it in yourself (or safely install a community-created extension)?
To work towards this, I’d like to prototype using an embedded scripting language to render UI. The interface should be defined declaratively and updated reactively.
Reactivity in Rust
- Leptos book
- Learning Leptos: Build a fine-grained reactive system in 100 lines of code
- leptos_reactive docs
- reactive_graph docs
- Pushing and Pulling: Three Reactivity Algorithms
Compose internals
Notes
- It’s probably too complicated to hook directly into Compose.
- Fragments rendered by scripts should be less complex, so maybe they can be less performant?
- Instead, generate a tree, serialize it, and render it in Compose
- No Compose
remember, the script host manages fragment state
- No Compose
- Need to forward events from Compose to script host
- Ideally, selectively recompose Compose trees
- Don’t naively render the tree top-down
- Maybe possible by identifying subtrees with IDs and selectively recomposing them
- Maybe by component boundaries?
- Again, fragments are small, so not a priority
Example flow:
- Mounted by Compose
- Host runs script
- Script returns tree with text, button w/ handler
- Rendered by Compose
- Button clicked
- Compose forwards event to host
- Host runs script (?)
- Script returns new tree
- Rerendered by Compose
Progress
- Calling a Rhai script that returns a tree of Maps
- Persisting values between calls by key
- Calling an “event handler” closure to modify a capture state value
- Rerendering with the new state values to get a new tree
2026-03-08
I spent a while yesterday messing with Rhai custom syntax, to have State objects with get() and set(y) with custom syntax for $x and $x = y respectively. The way it was implemented caused $x to not capture x in a closure, so you’d need to say let x = x; or something similar using the variable name without $ in order to capture. I replaced this with a magic state object that overrides indexing, so state.x gets the stored value, and state.x = y sets the stored value.
Positional memoization
It would also be nice to support having multiple independent stateful components. Their states will be stored in a keyed tree structure using positional memoization. The magic state objects need to be constructed by the runtime, so we need to annotate component instantiations. At each instantiation, we can push a path component on to the global render state, render the component, and then pop the path component. Then we can use the global render state’s current path to prefix stored values. We can use the source code position of the instantiation to uniquely identify the callsite.
This isn’t quite sufficient. For example, when instantiating components (hereby referred to as rendering a component) in a loop like for i in 0..5 { render Counter; }, each Counter render shares a source code position, but we don’t want each instance to share state. We can probably key by (position, call count), but we also need syntax to stably identify a component in case of insertions/deletions/reorders.
Another consideration is emit-style rendering (like in Compose) vs return-style rendering (like in React). In return-style rendering, you create and return objects to build up a tree of components. In emit-style rendering, call order determines child order, and there are no returned components to deal with at runtime. You can easily emit in control flow structures like conditionals or loops. emit-style uses global state to determine which element is the current node to append children to. We’re leaning towards emit-style for simplicity, since we already yield to the host before/after component renders which can be reused to track the current node.
Some new links about positional memoization today to supplement “Compose From First Principles”:
- Positional Memoization via Proc Macros in a Rust UI Framework | Tessera
- raphlinus/crochet: Explorations in reactive UI patterns
Syntax
Constraints:
- Likely no language server or autocomplete
- Dynamically typed
So the syntax should be simple and lightweight.
We compared:
- Compose
- Leptos
- Dioxus
- Maud
- JSX
- Svelte
The currently planned syntax is heavily inspired by Compose, but uses a special render keyword instead of function calls, mostly for ease of implementation in Rhai.
render Simple;
// with props
render Button (label = "inc", handler = || { ... });
// with children closure
render Stack (
direction = "row",
children = || { ... },
);
// {} desugars to children closure prop
render Stack (direction = "row") {
// ...
}
// with explicit key (ex. in arrays)
render Card [item.id] (label = item.title);
// with your choice of whitespace
render Component[key](prop=value){...}
// key block
render [key] { ... }
Since we have props now too, let’s define a component. A component is a function, conventionally named in PascalCase, which takes state and props. It can use render to render subcomponents, and can use control flow structures to do so. Functions should be called using the render keyword to correctly manage state.
fn Counter(state, props) {
state.count = state.count ?? props.initial ?? 0;
let x = props.change_by ?? 1;
render Stack (
direction = "column",
) {
render Button (label = "inc", onclick = || { state.count += x });
render Text (text = "Count: " + state.count);
render Button (label = "dec", onclick = || { state.count -= x });
if (state.count == 7 && props.when_seven) {
props.when_seven();
}
}
}
// in another component...
render Counter (
initial = 3, change_by = 2,
when_seven = || { render Text (text = "Lucky!"); },
);
Since we’re in dynamic land, all state variables start uninitialized, and all props are optional. We can throw in Rhai, but we should probably go with the flow and be liberal in what we accept.
We could also combine state and props into context, and access context.state.count or context.props.label. It’s unlikely we’ll need more than context than state/props though, so it’s fine as-is. We can also possibly add extensions (ex. React-like context) later using the global render state.
Other things we’ll likely have:
- Standard library of components
- Primitives (layout, text, icons, buttons, inputs, comboboxes, etc)
- How customizable? Should we limit UI extensions to the application’s design language?
- Styling system similar to Compose’s
Modifier, since that’s the planned UI host for now - Color system based on the application theme
Open questions
- Should we support pixel/vector drawing?
Progress
- Positional memoization progress (magic state, keyed tree)
- Render syntax design
- Render syntax implementation progress