Declarative UI: Node/mount and Store
moonbit-libyue provides three freely composable layers of syntactic sugar on top of the classic imperative API:
| Layer | Content | Typical scenario |
|---|---|---|
| L1 | X::make(...) props constructors, apply_style | Create a widget in one line (usable standalone) |
| L2 | Node tree + mount / vbox / label / button … | Declare the whole UI structure |
| L3 | Store[T] + bind_label | UI updates automatically when data changes |
For a full side-by-side example, see examples/showcase.
L1: props constructors
Every widget has an X::make constructor that merges "create + properties + callbacks" into a single expression. Apart from "content" parameters (such as a Label's text), everything is optional and named; omitting a parameter uses the default:
let btn = @yue.Button::make("OK", on_click=fn() { save() })
let slider = @yue.Slider::make(range=Some((0.0, 100.0)), step=Some(1.0))
let entry = @yue.Entry::make(entry_type=Password)
entry.on_activate(fn() { check(entry.get_text()) })Note that L1 constructors and their same-named L2 nodes differ in callback parameters: Entry::make takes entry_type / on_activate() (callback without arguments), while the L2 entry node takes password / on_enter(String) (callback receives the text, see the next section).
A single style array mixing numeric (Double) and string values is available on almost every constructor:
@yue.Label::make("Title", style=[("marginBottom", 10.0), ("color", "#356AA0")])To batch-apply styles to an existing widget, use the free function apply_style(view, style=...).
L2: the Node tree and mount
A Node represents "a UI fragment not yet mounted". Constructing nodes only builds the tree; widgets are actually created and callbacks registered at mount time — so the same code can declare first and assemble later:
fn page(state : State) -> @yue.Container {
@declarative.mount([
@declarative.label("Settings", style=[("color", "#356AA0")]),
@declarative.entry(text="Nickname", on_enter=fn(s) { state.save(s) }),
@declarative.hbox([
@declarative.button("Save", on_click=fn() { state.flush() }),
@declarative.button("Cancel"),
]),
])
}
win.set_content(page(state)) // mount returns the root Container; feed it straight to the windowWindows as the declarative root: mount_window
A Window has no parent view, so instead of being a Node it serves as the mount entry point: it creates the window, mounts the subtree as its content, and returns the window handle; non-view assets such as menu bars and tray icons are attached via handle. The window is activated and shown right after handle returns — feed the result straight into run, no manual activate needed; adjustments that must happen before the window shows go inside handle:
let win = @declarative.mount_window(
[
@declarative.label("Hello"),
@declarative.button("Quit", on_click=fn() { @yue.quit() }),
],
title="Demo",
size=Some((960.0, 640.0)),
center=true,
handle=fn(w) { w.set_menubar(build_menubar()) },
)Node constructor overview
| Node | Corresponding widget | Notes |
|---|---|---|
vbox(children, …) / hbox(children, …) | Container | vertical / horizontal layout |
container(on_draw, handle, …) | Container | custom-paint canvas / get container handle |
label(text, …) | Label | text follows the theme regular color by default (changes with theme_apply); fixed colors via handle set_color |
button(title, on_click, …) | Button | |
checkbox(title, checked, on_change, …) | Checkbox | on_change(Bool) |
radio(title, checked, on_change, …) | Radio | mutually exclusive within a group |
entry(text, password, on_enter, on_input, …) | Entry | callbacks receive the text |
text_edit(text, on_input, …) | TextEdit | callback receives the text |
slider(value, range, step, on_change, …) | Slider | on_change(Double) |
progress(value, indeterminate, …) | ProgressBar | |
picker(items, selected, on_change, …) | Picker | |
combo(items, selected, on_select, on_input, …) | ComboBox | |
group(title, content, …) | Group | content is a single Node |
scroll(content, content_size, policy, …) | Scroll | content is a single Node; when the content is mounted later or grows taller, call Scroll::refresh_content_size via the handle to refresh the range |
separator(orientation) | Separator | |
tab(pages, on_change, …) | Tab | each page gets an automatic container |
date_picker(epoch, on_change) | DatePicker | |
gif(image, scale) | GifPlayer | |
browser(url, html, …) | Browser (in the yue/browser package, @browser.browser(...)) | one of the two |
bind_label(store, f, …) | Label | L3 reactive binding, see below |
All nodes accept style; common nodes also have a handle parameter.
handle: getting the widget handle back
In a declarative tree, widgets only exist once mounted. If you want to imperatively operate on a widget after mounting (update a progress bar, focus an input…), pass a handle callback that receives the concrete handle at mount time:
let bar : Ref[@yue.ProgressBar?] = Ref(None)
@declarative.progress(handle=fn(p) { bar.val = Some(p) })
// any time later: p.set_value(0.5) on the value in bar.valMixing in imperative code: node_of
Any existing ViewLike widget can be wrapped as a node and embedded in a declarative tree:
@declarative.node_of(my_legacy_view, style=[("marginBottom", 8.0)])Custom nodes
Node is an open structure (a single mount-function field); to wrap your own composite widget, just write an ordinary function returning a Node; for lower-level control you can write a literal directly:
fn tagged(label_text : String, body : Node) -> Node {
@declarative.vbox([@declarative.label(label_text), body])
}Custom component recipes (summarized from examples/showcase)
- Composite components (recommended): an ordinary function returning a Node; parameters are props, closures are private state (e.g.
card/nav_item). - Drawn components:
container(on_draw=...)+ Painter draws badges etc. with zero image assets. - Stateful components: the component holds a private
Store, refreshed automatically viabind_label; each instance has independent state (e.g.counter_widget). Cross-component coordination uses a shared Store +mapderivation (the sidebar highlight works this way). - Extending built-in nodes:
vbox/hboxaccepthandle(returns the Container handle at mount time, for background colors etc.). Note: theparent : Viewreceived by aNode'smountonly hasattachavailable inside theyuepackage — outside the package you cannot write a Node literal that mounts a container into the parent directly; extend the library instead, or wrap existing views withnode_of. - Modern layouts: plain vbox/hbox/scroll flex boxes can produce a "dark sidebar + header bar + scrolling cards" shell; the key points are that the root node needs
style=[("flex", 1.0)]to fill the window, the fixed-width sidebar setswidthwithout flex, the main area takesflex=1, and the root container usesstyle=[("alignItems", "stretch")]so child columns fill the height. - Shrink semantics:
vbox/hboxdo not shrink by default (yoga semantics,flexShrink 0); overflowing children are clipped. Containers that need to shrink or wrap give it explicitly viastyle=[("flexshrink", 1.0)](e.g. segmented multi-row wrapping — the width constraint required by wrap is propagated through it). Trade-off measured in practice: defaulting to 1 squeezes fixed-size drawn widgets (icons, inputs) across the board, so the default stays 0 and is opted into explicitly.
Component library (the yue/components package, split into per-family files)
Element-Plus-style non-form components built on top of the declarative layer, pure MoonBit with zero platform code:
side_menu(items, selected)— sidebar navigation (hover grey, selected light-blue + accent bar, syncs pages viaset_visible);segmented(options, selected)— segmented control / top-bar navigation;tag/tag_of_type— labels (solid custom color / five semantic types);breadcrumb·pagination·steps·alert/alert_closeable·timeline·collapse·descriptions·result·empty·statistic·avatar·badge_count/badge_dot·card;code_view(lines)— syntax-highlighted code viewsegmented(options, selected)— segmented control / top-bar navigation (selected item floats on white);tag(text, color)— colored rounded label (width auto-fits the text at mount time);code_view(lines)— syntax-highlighted code view: one AttributedText per token (whole-range coloring) measured and drawn manually. Visually equivalent to range coloring and consistent across platforms — on Windows, AttributedText range font/color is an upstream deficiency (see adaptation.md); this approach bypasses it and is a viable alternative for code highlighting / terminal rendering. The built-intokenize_moonbitis a demo tokenizer; consumers can feed any lexical analysis result.
Component state coordination goes through Store; main-area page switching uses "subscribe to Store + ViewLike::set_visible" (the ABI was added on 2026-09-16). Full demo in examples/showcase (sidebar + top bar + page switching + code page); component list in docs/components-ui.md.
L3: Store and bind_label
A Store[T] is a subscribable value: set notifies all subscribers, map derives read-only views, and bind_label plugs a Store into a declarative tree — when the state changes, the text updates automatically:
let count : @yue.Store[Int] = @yue.Store::new(0)
win.set_content(@declarative.mount([
@declarative.bind_label(count, fn(n) { "Clicked \{n} times" }),
@declarative.button("Click me", on_click=fn() { count.update(fn(n) { n + 1 }) }),
]))Clicking the button → count changes → the bind_label text automatically becomes "Clicked 1 times". Where you don't use a Store, keep using handle + setter as before; both coexist.
To swap an arbitrary node (not just text) on state change, use bind_node: it remounts the whole subtree when the signal changes, which suits low-frequency switches. ⚠ Windows limitation (upstream libyue bug, see adaptation.md): remounting a subtree that contains the clicked control itself from inside a click handler corrupts subsequent mouse hit-testing (every click gets mis-routed, irreversibly) — for such cases use swap_node, which pre-builds both nodes and toggles visibility (no destroy/recreate; the display:none path):
// swapping whole blocks (not on the clicked control's own chain): bind_node
@yue.bind_node(mode.signal(), fn(m) {
if m { @components.input_t(text="Edit mode") } else { @components.label_t("Read-only") }
})
// icon toggles driven by their own clicks: swap_node on Windows
@yue.swap_node(dark.signal(),
@icons.icon_button_t(@icons.Moon, on_click=..., tip="Switch to dark"),
@icons.icon_button_t(@icons.Sunny, on_click=..., tip="Switch to light"))For high-frequency updates prefer bind (text) or imperative setters via handle.
API overview:
| Function | Description |
|---|---|
Store::new(v) | create |
get() / set(v) | read / write and notify |
update(f) | set(f(get())) |
subscribe(f) | subscribe; no callback at registration time, read the initial value via get directly |
map(f) | derive a Store that follows the source automatically on change (chainable) |
bind_label(store, f, color? = "", …) | bind text inside a declarative tree; f maps the state to a string; color overrides the text color (default: theme regular text color) |
bind(sig, f, color? = "", …) | Signal version of bind_label; accepts source or computed signals |
bind_node(sig, f) | bind an arbitrary node: remounts the subtree as f(value) whenever the signal changes — for low-frequency switches; on Windows, do not use in click chains that remount the clicked control itself (see the warning above; pass store.signal() for a Store) |
swap_node(sig, a, b) | two-state swap: pre-build both nodes and toggle visibility by the signal — no destroy/recreate, the Windows-safe alternative to bind_node at the cost of keeping both subtrees alive |
Signal: derived values with automatic dependency tracking
Signal[T] shares the same core as Store and adds automatic dependency tracking: inside Signal::computed(fn() { ... }), every signal read becomes a dependency automatically; when any dependency changes, the derived signal is invalidated and recomputed lazily on the next read — the derivation is declared where it is defined, with no manual set of another Store inside action callbacks:
let count = @yue.Signal::new(0)
let doubled = @yue.Signal::computed(fn() { count.get() * 2 })
@declarative.bind(doubled, fn(n) { "Doubled: \{n}" }) // text binding, signal edition
@declarative.button("Click me", on_click=fn() { count.update(fn(n) { n + 1 }) })Multiple set calls inside the same event callback trigger one notification round each; wrap them in batch to merge:
@yue.batch(fn() {
a.set(1)
b.set(2) // subscribers are notified once, after the batch ends
})API overview:
| Function | Description |
|---|---|
Signal::new(v) | create a source signal |
Signal::computed(f) | derived signal; signals read in f become dependencies automatically, recomputed lazily |
get() / set(v) / update(f) | same semantics as Store |
subscribe(f) | same semantics as Store (callback receives the latest value) |
map(f) | one-to-one derivation, equivalent to a computed reading a single source |
batch(fn) | batch: multiple sets inside fn merge into one notification; nestable |
bind(sig, f, …) | bind text inside a declarative tree; accepts source or computed signals |
Signal::store() / Store::signal() | zero-cost conversion in both directions (shared value and subscriptions); pass sig.store() to component APIs taking a Store |
Inherent pitfalls
- Nodes are instantiated only at mount time: when
button(...)returns, the widget does not exist yet — do not save widget references while building the tree; if you need a reference, usehandle(triggered at mount time). A tree is normallymounted only once; mounting the same node again instantiates a second copy of the widget. handleis not calledref:refis also a MoonBit reserved word.- Store has no unsubscription: subscriptions live for the whole application lifetime, and
setdoes not deduplicate (identical values still notify). Callingseton another Store inside a subscription callback is safe (snapshot iteration), but do not let two Stores trigger each other into an infinite loop. - bind_label's subscription happens at mount time: an unmounted bind node does not subscribe and receives no notifications; the initial value is rendered at mount time directly with
f(store.get()). - Checkbox/radio initialization callbacks: after
checkbox/radiomounts withchecked=true, it asynchronously receives oneon_changewhen entering the event loop (GTK toggled-signal semantics); when a radio group switches, the old item that got deselected also receives oneon_change(false). Base business logic onis_checked(). - Heterogeneous children only via Node: MoonBit traits cannot be used as array element types, so
Array[ViewLike]cannot hold mixed widgets — this is exactly whyNodeexists; at theX::makelayer, the single-content parameters (Group::make/Scroll::make) accept concrete widgets directly.