Skip to content

Declarative UI: Node/mount and Store ​

moonbit-libyue provides three freely composable layers of syntactic sugar on top of the classic imperative API:

LayerContentTypical scenario
L1X::make(...) props constructors, apply_styleCreate a widget in one line (usable standalone)
L2Node tree + mount / vbox / label / button …Declare the whole UI structure
L3Store[T] + bind_labelUI 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:

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

moonbit
@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:

moonbit
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 window

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

moonbit
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 ​

NodeCorresponding widgetNotes
vbox(children, …) / hbox(children, …)Containervertical / horizontal layout
container(on_draw, handle, …)Containercustom-paint canvas / get container handle
label(text, …)Labeltext 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, …)Checkboxon_change(Bool)
radio(title, checked, on_change, …)Radiomutually exclusive within a group
entry(text, password, on_enter, on_input, …)Entrycallbacks receive the text
text_edit(text, on_input, …)TextEditcallback receives the text
slider(value, range, step, on_change, …)Slideron_change(Double)
progress(value, indeterminate, …)ProgressBar
picker(items, selected, on_change, …)Picker
combo(items, selected, on_select, on_input, …)ComboBox
group(title, content, …)Groupcontent is a single Node
scroll(content, content_size, policy, …)Scrollcontent 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, …)Tabeach 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, …)LabelL3 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:

moonbit
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.val

Mixing in imperative code: node_of ​

Any existing ViewLike widget can be wrapped as a node and embedded in a declarative tree:

moonbit
@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:

moonbit
fn tagged(label_text : String, body : Node) -> Node {
  @declarative.vbox([@declarative.label(label_text), body])
}

Custom component recipes (summarized from examples/showcase) ​

  1. Composite components (recommended): an ordinary function returning a Node; parameters are props, closures are private state (e.g. card/nav_item).
  2. Drawn components: container(on_draw=...) + Painter draws badges etc. with zero image assets.
  3. Stateful components: the component holds a private Store, refreshed automatically via bind_label; each instance has independent state (e.g. counter_widget). Cross-component coordination uses a shared Store + map derivation (the sidebar highlight works this way).
  4. Extending built-in nodes: vbox/hbox accept handle (returns the Container handle at mount time, for background colors etc.). Note: the parent : View received by a Node's mount only has attach available inside the yue package — 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 with node_of.
  5. 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 sets width without flex, the main area takes flex=1, and the root container uses style=[("alignItems", "stretch")] so child columns fill the height.
  6. Shrink semantics: vbox/hbox do not shrink by default (yoga semantics, flexShrink 0); overflowing children are clipped. Containers that need to shrink or wrap give it explicitly via style=[("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 via set_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 view
  • segmented(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-in tokenize_moonbit is 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:

moonbit
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):

moonbit
// 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:

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

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

moonbit
@yue.batch(fn() {
  a.set(1)
  b.set(2)   // subscribers are notified once, after the batch ends
})

API overview:

FunctionDescription
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 ​

  1. 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, use handle (triggered at mount time). A tree is normally mounted only once; mounting the same node again instantiates a second copy of the widget.
  2. handle is not called ref: ref is also a MoonBit reserved word.
  3. Store has no unsubscription: subscriptions live for the whole application lifetime, and set does not deduplicate (identical values still notify). Calling set on another Store inside a subscription callback is safe (snapshot iteration), but do not let two Stores trigger each other into an infinite loop.
  4. 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()).
  5. Checkbox/radio initialization callbacks: after checkbox/radio mounts with checked=true, it asynchronously receives one on_change when entering the event loop (GTK toggled-signal semantics); when a radio group switches, the old item that got deselected also receives one on_change(false). Base business logic on is_checked().
  6. Heterogeneous children only via Node: MoonBit traits cannot be used as array element types, so Array[ViewLike] cannot hold mixed widgets — this is exactly why Node exists; at the X::make layer, the single-content parameters (Group::make / Scroll::make) accept concrete widgets directly.