home notes

Scriptable UI

Goals:

Requirements:

Non-requirements:

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

Compose internals

Notes

Example flow:

Progress

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”:

Syntax

Constraints:

So the syntax should be simple and lightweight.

We compared:

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:

Open questions

Progress