Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

The Grimoire’s Preface

xrune is an engine of inscription.

You hand it a casting — a declarative cast written inside ui! { … } — and at compile time it reads the shape of that cast, transcribes the shape into runes, and lets the inscriber you have bound (a DsRune implementation) inscribe those runes into actual code. A final seal closes the rite, and the whole casting condenses into a TokenStream your host crate absorbs as if it had written it by hand.

That separation is the entire design. The same casting can be inscribed into ECS spawn calls, into a render-tree builder, into a debug echo, or round-tripped by the scribe. What it becomes is decided not by the casting, but by which inscriber you bind.

The casting does not know what a “widget” is. The casting knows only shape.

When to summon xrune

  • Your host already owns its components, its state, its render-loop, and you want a casting layer above them — without wedding the casting to any one type system.
  • Several inscribers must share one casting: an ECS-runtime inscriber, a pretty-printing inscriber, a static-analysis inscriber — all reading the same rite.
  • The rendering target is not yet decided, but the surface casting is.

If your DSL needs compile-time type-checking on widget names and attribute keys, look elsewhere — toward a typed-builder DSL. xrune hands every shred of semantics to the inscriber.

Five volumes and a hub

VolumeSuffixOffice
xruneThe opening scroll. The reader summons only this; it draws xrune-nexus and the ui! rite from beneath.
xrune-nexusnexusThe hub. AST nodes (Ds*), the DsRune covenant, the decipher walk — all kept here.
xrune-incantincantThe speaking-stone. The proc-macro that is ui! { … }.
xrune-sigilsigilThe sigil-forge. The DsRef derive macro that mints Rc<RefCell<>> reference-sigils for the AST.
xrune-fmtfmtThe scribe. A CLI that puts every ui! { … } block through the real parser and writes it back, faithful in shape.

All five volumes share one cargo workspace and one moving version: every release advances all five together to the same X.Y.Z.

The naming compact

xrune leans on a medieval-magical vocabulary because the architecture is shaped that way: one casting, many translations. A reader who meets an unfamiliar term should look it up once in the Lexicon and never need to look it up again. The shortest summary:

  • rune / inscriber: a backend; an implementation of the DsRune trait.
  • decipher: the walk function. It traverses the AST and feeds every node to the inscriber’s inscribe methods.
  • inscribe: what the inscriber does at every node — laying each rune into the emitted code.
  • seal: when the walk ends, the inscriber closes its work into a final TokenStream.

That is the whole conceptual surface. Every other word — sigil, niche, enchant, walk, on — is a particular shape of casting; the chapters ahead take them in turn.

How the grimoire is laid out

  • Part I — First Casting takes you from cargo new to seeing decipher actually walk, then catalogues every casting shape.
  • Part II — The Inner Mechanism explains the AST nodes and teaches you to forge an inscriber of your own.
  • Part III — The Outer Tools covers the scribe and the workshop (the cargo workspace itself).
  • Part IV — Time & Lore records the cross-version drift of forms, and sets xrune beside neighbouring DSL approaches.

The Appendix is the term-by-term lexicon and the public-API index.

House style

  • Code blocks are real, copy-paste-runnable Rust unless marked otherwise. The few that demonstrate ui! parser output without arranging a host environment are flagged in place.
  • Type names, error messages, and CLI strings are quoted from the source. They are never paraphrased.

The First Incantation

A five-minute walk from cargo new to seeing decipher actually run.

The example does not render a UI — there is no widget runtime in this book. What it produces is the proc-macro expansion of the bundled DefaultRune: a stream of println! calls that traces every node the parser hands to the rune. That is the entire learning surface for this chapter. Real backends come later in Binding the Rune.

Set up

cargo new hello-xrune
cd hello-xrune

Cargo.toml:

[package]
name = "hello-xrune"
version = "0.1.0"
edition = "2024"

[dependencies]
xrune = "1.5"

The minimum cast

src/main.rs:

use xrune::ui;

fn app(parent: i32) {
ui! {
    :(
        parent: parent
    :)

    container (width: 100, height: 100) {}
}
}

fn main() {
    app(0);
}

⚠ The ▶ button posts to play.rust-lang.org, which doesn’t carry the xrune crate, so the run will fail there. Use the eye ( 👁 ) toggle to reveal the full program, copy it into a local cargo new project with xrune = "1.5" in Cargo.toml, and cargo run.

Run cargo run locally. You will see the DefaultRune trace:

inscribe_root: 0
inscribe_widget: container, attrs: [width: 100, height: 100], children: []

(Exact strings depend on the version; the structure does not.)

That’s it — the parser accepted the ui! block, the decipher walker visited every node, and the bundled rune printed what it saw. Nothing else happened. No widgets exist; no window opened.

A slightly larger cast

The canonical fixture in examples/example0 exercises every Phase-1 syntax form:

use xrune::ui;

static A: i32 = 20;

fn app(parent: i32) {
ui! {
    :(
        parent: parent
    :)

    div (
        width: 100,
        height: 100 + A,
        color: "red"
    ) {
        text (content: "hello world") {
            picker (values: vec!["1", "2", "3"]) {

            }
        }

        walk range(20) with i {
            button (text: 6) {}
        }

        if a == "1" {
            input {

            }
        }
    }
}
}

fn main() {}

Things to read out of this:

  • The :( ... :) block (with parent: parent on its own line) is the context area. parent is the only required key; the rune sees it via DsRoot::get_parent().
  • width: 100, height: 100 + A, color: "red" — attribute values are arbitrary syn::Expr. 100 + A is a Rust expression, not a string.
  • text (content: "hello world") { picker (…) {} } — children nest. The parser builds a tree of DsTree cells; the rune decides what nesting means.
  • walk range(20) with i { … } — iteration. range(20) is not a standard-library function. This example compiles only as far as the proc-macro expansion; the expanded code references symbols that don’t exist in plain Rust. That’s fine for learning the syntax; for a runnable end-to-end example you need a real rune (Part II).
  • if a == "1" { … } — conditional. Likewise, a is a free identifier here.

What just happened, in one paragraph

ui! { … } is a proc macro shipped by xrune-incant. At expansion time it parses the token stream into a DsRoot (the AST root), constructs the bundled DefaultRune, calls inscribe_root with the context’s parent expression, and then runs decipher over the children. Each visited node — widget, if, walk, @niche, match — triggers one inscribe_* method on the rune. At the end the rune is sealed and its accumulated TokenStream becomes the macro’s output. For DefaultRune that output is a sequence of println! calls, which is why this example “runs” without a UI runtime.

Next

The Casting Syntax

Every form the ui! { … } macro accepts. Six axes:

  • Context Area — the :( … :) header that opens every cast.
  • Widget Nodes — the heart of the language: named or positional attrs, optional parens, optional body.
  • Enchants — the [expr, expr, …] block that attaches arbitrary data to a widget.
  • Control Flowif, walk … with …, @niche, match.
  • The on Handlers — event clauses in two forms (B and C), body or callback, qualified or bare, with or without args.
  • Rejected Forms — what the parser refuses, and why.

The DSL is untyped at parse time. Widget names are arbitrary identifiers; attribute values are arbitrary syn::Expr. All semantics — what counts as a widget, which attrs are valid, what an event kind means — live in the rune you bind, never in xrune itself.

Context Area

Every ui! { … } block opens with a context header:

use xrune::ui;
fn app(parent_expr: i32) {
ui! {
    :(
        parent: parent_expr
    :)

    placeholder {}
}
}
fn main() {}

:( and :) are literal token pairs. Inside live one or more attributes in the same name: value shape used elsewhere in the DSL. Each attribute sits on its own line.

What parent means

parent is the only required context key. The parser rejects a header without it:

Root node must have a parent

The rune retrieves the parent expression via DsRoot::get_parent(). For DefaultRune this is the value passed to inscribe_root; a real backend typically threads it as the spawn-under / mount-on entity for the rest of the cast.

Other keys are rune-defined

get_context_attrs() returns every attribute the header carried. xrune itself only consumes parent; everything else is yours to interpret:

use xrune::ui;
struct App { world: u32 }
enum Theme { Dark }
fn run(root_entity: i32, app: &mut App) {
ui! {
    :(
        parent: root_entity,
        world: &mut app.world,
        theme: Theme::Dark
    :)
    placeholder {}
}
}
fn main() {}

A rune that doesn’t understand world simply doesn’t read it. There is no compile-time validation that theme is a real symbol — that’s the rune’s job, in inscribe_root or in a sealing pass.

Multi-line layout

The header always spans multiple lines, with each attribute on its own line:

use xrune::ui;
struct App { world: u32 }
enum Theme { Dark }
fn run(root_entity: i32, app: &mut App) {
ui! {
:(
    parent: root_entity
    world: &mut app.world
    theme: Theme::Dark
:)
placeholder {}
}
}
fn main() {}

Putting two or more attributes on the same line raises a parse error:

root header must be multi-line — put each context attr on its own line

Commas between attributes are optional — the shape below is equally valid:

use xrune::ui;
fn run(parent: i32, world: &mut u32) {
ui! {
:(
    parent: parent,
    world: &mut world,
:)
placeholder {}
}
}
fn main() {}

Source-of-truth

DsRoot::parse in crates/xrune_nexus/src/ds_node/ds_root.rs. The behaviour above is exercised by root_header_* tests in crates/xrune_nexus/src/tests.rs.

Widget Nodes

The most-used form. The parser produces a DsWidget node carrying:

  • a name (syn::Ident),
  • attrs (Vec<DsAttr>),
  • enchants (Vec<syn::Expr>),
  • on-handlers (Vec<DsOn>),
  • children (Vec<DsTreeRef>).

Backends consume the lot through inscribe_widget(name, attrs, enchants, on_handlers, children).

All shapes

use xrune::ui;
fn Text(s: &'static str) -> &'static str { s }
#[derive(Debug)]
struct DisabledMarker;
fn app(parent: i32) {
let Disabled = DisabledMarker;
ui! {
    :(
        parent: parent
    :)
root_widget {
    /* Full: name + named attrs + body. */
    container (width: 480, height: 320, color: "dark") {
        placeholder {}
    }

    /* No parens: zero attrs. */
    container {}

    /* No body: zero children. */
    header (height: 40, text: "Hello")

    /* Empty body equals no body. */
    header (height: 40, text: "Hello") {}

    /* Positional attrs (DsAttr.name = None). */
    text ("hello world")
    button (Text("Save"), Disabled)
}
}
}
fn main() {}

Named vs positional attrs

DsAttr carries name: Option<syn::Ident>. The parser tries the name: value shape first and falls back to bare-expression positional attrs. Mixing both in the same widget is allowed — order is preserved.

use xrune::ui;
fn Text(s: &'static str) -> &'static str { s }
#[derive(Debug)]
struct DisabledMarker;
fn app(parent: i32) {
let Disabled = DisabledMarker;
ui! {
    :(
        parent: parent
    :)
button (Text("Save"), priority: 1, Disabled)
}
}
fn main() {}

name_str() gives the rune the attr name as Option<&str> for matching; positional attrs come back as None.

Attribute values are real Rust

Attribute values are syn::Expr. Anything that parses as a Rust expression works:

use xrune::ui;
use std::fmt;
struct State;
impl State { fn set(&self, _: u32) {} }
struct DebugClosure<F>(F);
impl<F> fmt::Debug for DebugClosure<F> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("<closure>") }
}
fn app(parent: i32, items: Vec<u32>, state: State) {
let on_change = DebugClosure(move |v| state.set(v));
ui! {
    :(
        parent: parent
    :)
slider (
    min: 0,
    max: items.len() as u32,
    step: 1.0 / 60.0,
    on_change: on_change
)
}
}
fn main() {}

The rune decides what to do with each value. There is no compile-time schema mapping attr names to types.

Children nest as full trees

use xrune::ui;
#[derive(Debug)]
struct Item { name: String }
fn app(parent: i32, items: Vec<Item>, item: &Item) {
ui! {
    :(
        parent: parent
    :)
window (title: "Cast") {
    column (gap: 8) {
        header (text: "xrune")
        list (items: items.iter()) {
            row (text: item.name)
        }
    }
}
}
}
fn main() {}

Each child is itself a DsTree, so widgets, if, walk, @niche, and match can all nest inside another widget’s body. The combination is unrestricted — the rune enforces what’s actually meaningful.

Source-of-truth

crates/xrune_nexus/src/ds_node/ds_widget.rs. Tested by parse_widget_* cases in tests.rs.

Enchants

An enchant is a bracketed expression list attached to a widget, sitting between the attrs and the body:

use xrune::ui;
struct Velocity { vx: i32, vy: i32 }
struct Collider;
impl Collider { fn circle(_: i32) -> Self { Self } }
fn app(parent: i32) {
ui! {
    :(
        parent: parent
    :)
physic_obj (x: 100, y: 200) [
    Velocity { vx: 1, vy: 0 },
    Collider::circle(10),
] {}
}
}
fn main() {}

Enchants are arbitrary syn::Expr values — typically component literals, struct constructors, or marker tags. The rune retrieves them via DsWidget::get_enchants() and decides what to do (spawn them as ECS components, attach them as middleware, store them on the entity).

Position

The order is fixed: name (attrs) [enchants] { children }. All four parts after name are independently optional:

use xrune::ui;
#[derive(Debug)] struct TagMarker;
#[derive(Debug)] struct Tag1Marker;
#[derive(Debug)] struct Tag2Marker;
fn app(parent: i32) {
let Tag = TagMarker;
let Tag1 = Tag1Marker;
let Tag2 = Tag2Marker;
ui! {
    :(
        parent: parent
    :)
root_widget {
    foo                                     /* no parens, no enchants, no body */
    foo (a: 1)                              /* attrs only */
    foo (a: 1) [Tag] {}                     /* attrs + enchants + empty body */
    foo [Tag1, Tag2] {}                     /* enchants without attrs */
    foo () [Tag] {}                         /* equivalent: empty attrs + enchants */
}
}
}
fn main() {}

The rune always receives a Vec for each slot — empty vectors when the shape was omitted.

Use-case shape

Enchants are intentionally untyped — they’re how a rune lets users attach anything to a node without reserving a syntactic slot for it. A typical ECS-shaped rune turns each enchant expression into a component inserted onto the spawned entity:

use xrune::ui;
struct Position { x: f32, y: f32 }
struct Health(i32);
const PlayerControlled: () = ();
fn app(parent: i32) {
ui! {
    :(
        parent: parent
    :)
sprite (texture: "hero.png") [
    Position { x: 0.0, y: 0.0 },
    Health(100),
    PlayerControlled,
] {}
}
}
fn main() {}

A debug rune simply prints them. xrune-fmt re-emits them verbatim.

Source-of-truth

The parsing branch lives in DsWidget::parse (ds_widget.rs). inscribe_widget carries an enchants: &[syn::Expr] slice for the rune to consume.

Control Flow

Four shapes: if, walk … with …, @niche, match. All four sit at the same level as widget nodes — they can be nested anywhere a widget can be.

if — conditional render

use xrune::ui;
fn app(parent: i32, show_footer: bool) {
ui! {
    :(
        parent: parent
    :)
if show_footer {
    footer (height: 20) {}
}
}
}
fn main() {}

The condition is a full syn::Expr parsed without consuming braces (so the body block is a separate { … }). The body is required — a bodiless if is a parse error.

The rune sees this through inscribe_if(condition, children). There is no else arm at the DSL level; render two if blocks with negated conditions, or use match for binary cases.

walk … with … — iteration

use xrune::ui;
#[derive(Debug)]
struct Item { name: String }
fn app(parent: i32, items: Vec<Item>, item: &Item) {
ui! {
    :(
        parent: parent
    :)
walk items.iter() with item {
    label (text: item.name)
}
}
}
fn main() {}

Reads as: iterate items.iter(); for each value, bind it to item in the children. The iterable is a syn::Expr, the binding is a syn::Ident, the body is required.

walk and with are reserved keywords — neither can be used as a widget name.

The rune sees this through inscribe_iter(iterable, variable, children).

@niche — named anchor

use xrune::ui;
fn app(parent: i32) {
ui! {
    :(
        parent: parent
    :)
@settings_panel {
    toggle (label: "Dark mode")
    slider (label: "Volume", min: 0, max: 1)
}
}
}
fn main() {}

A niche is a @-prefixed identifier carrying a body. The semantics are entirely the rune’s: a portal slot, a named region, a router target, a template hole. The parser only guarantees the shape @name { children }.

The rune sees inscribe_niche(name, children).

match — pattern matching

use xrune::ui;
#[derive(Debug)]
enum State { Loading, Ready(Vec<i32>) }
fn app(parent: i32, state: State, data: &Vec<i32>) {
ui! {
    :(
        parent: parent
    :)
match state {
    State::Loading => {
        spinner {}
    }
    State::Ready(data) => {
        list (items: data.iter()) {}
    }
    _ => {
        empty {}
    }
}
}
}
fn main() {}

Each arm carries its own syn::Pat and a sub-tree of children. Patterns support all the things Pat::parse_multi_with_leading_vert accepts — bindings, wildcards, | alternatives, struct destructuring. Optional trailing comma per arm.

The rune sees inscribe_match(scrutinee, arms) and is responsible for walking each arm’s get_children() itself.

Bodies are mandatory

All four control nodes require a brace body. Bodiless if, walk, @niche, and match are parse errors — they would be no-ops.

Source-of-truth

The on Handlers

The on EventKind clause attaches event handlers to a widget.

on is a reserved keyword — registered as a custom token so on Foo is never mistaken for a widget named on.

Form B — modifier chain after the body

use xrune::ui;
fn save() {}
fn app(parent: i32) {
ui! {
    :(
        parent: parent
    :)
button (text: "Save") {} on Tap {
    save();
}
}
}
fn main() {}

Inside a nested body, Form-B attaches to the nearest preceding sibling widget, not to the parent:

use xrune::ui;
fn save() {}
fn cancel() {}
fn app(parent: i32) {
ui! {
    :(
        parent: parent
    :)
column {
    button (text: "Save") {}
    on Tap { save(); }            /* attaches to the button above */

    button (text: "Cancel") {}
    on Tap { cancel(); }          /* attaches to the cancel button */
}
}
}
fn main() {}

Multiple Form-B clauses chain on the same widget:

use xrune::ui;
fn save() {}
fn hint() {}
fn app(parent: i32) {
ui! {
    :(
        parent: parent
    :)
button (text: "Save") {}
    on Tap { save(); }
    on Hover { hint(); }
}
}
fn main() {}

Form C — between attrs and body

use xrune::ui;
fn commit() {}
fn lock() {}
fn app(parent: i32) {
ui! {
    :(
        parent: parent
    :)
slider (min: 0, max: 100)
    on ValueChanged(2) { commit(); }
    on DragStart { lock(); }
    {}
}
}
fn main() {}

Multiple Form-C clauses stack on the same widget. The trailing {} is optional; without it, the widget simply has no children:

use xrune::ui;
fn fire() {}
fn app(parent: i32) {
ui! {
    :(
        parent: parent
    :)
view ()
    on Tap { fire(); }
}
}
fn main() {}

Form B and Form C accumulate into the same Vec<DsOn> retrieved via DsWidget::get_on_handlers(). Mixing both on one widget is allowed.

Body form vs callback form

A handler can carry either a body block:

use xrune::ui;
struct State; impl State { fn toggle(&self) {} }
fn app(parent: i32, state: State) {
ui! {
    :(
        parent: parent
    :)
button () {}
on Tap {
    state.toggle();
}
}
}
fn main() {}

…or a trailing callback expression with no body:

use xrune::ui;
fn callback() {}
fn app(parent: i32) {
ui! {
    :(
        parent: parent
    :)
button () {}
on Tap(callback)
on Tap(2, callback)
}
}
fn main() {}

For the body form, DsOn::get_body() returns Some(&syn::Block). For the callback form, it returns None, and the rune decides what the trailing get_args() element means — the convention is “callable expression to invoke when the event fires.”

A clause with neither body nor args is a parse error.

Qualified events

use xrune::ui;
struct Slider;
fn commit() {}
fn app(parent: i32) {
ui! {
    :(
        parent: parent
    :)
slider () {}
on Slider::ValueChanged { commit(); }
}
}
fn main() {}

get_qualifier() returns Some(Slider) and get_name() returns ValueChanged. Only one segment of qualification is allowed — Foo::Bar::Baz is rejected.

Args

Arguments inside on EventKind(…) are a comma-separated list of syn::Expr. The rune retrieves them via get_args(). Common shapes:

on Tap { … }                        /* args: [], body present */
on Tap(2) { … }                     /* args: [2], body present */
on Tap(cb)                          /* callback form, body absent */
on Tap(2, cb)                       /* count + callback */

A clause with neither body nor args is a parse error: every on must carry at least one of the two.

Source-of-truth

ds_on.rs and the form_b_* / form_c_* test cases in tests.rs. The shape is exhaustively round-tripped by xrune-fmt’s formatter — every change to this surface lands a fmt update in the same commit.

Rejected Forms

Not every shape that looks sensible parses. The cases below all raise deliberate parse errors — the parser favours rejecting ambiguous casts over guessing.

Context area

What you wroteWhy it fails
:( :) (no parent)Root node must have a parent
:( foo: 1 :)Same — missing parent key
:( parent: r world: w :) (multi-attr, single line)Multi-line required when >1 attr

if / walk / @niche / match without bodies

if cond                /* parse error: body required */
walk it with x         /* parse error */
@slot                  /* parse error */
match e                /* parse error */

Bodyless control nodes would be no-ops, so the parser refuses them outright rather than silently accept dead syntax.

on shapes

RejectedReason
on Tap { … } at the root, no preceding widgeton requires a sibling to attach to
on Tap call_me() {}A handler must be either a body block or (args) form, not a bare call
on Foo::Bar::Baz { … }Qualifier supports a single segment only
on Tap with no body and no argsA handler must carry at least one of the two

Niche names are single identifiers

@foo::bar { } is not parsed — niche names are a single syn::Ident. Patterns inside match use Pat::parse_multi_with_leading_vert, so any pattern syn accepts in a regular match arm works.

What’s not an error

A widget without children is fine — header (text: "x") and header (text: "x") {} parse identically. So is a widget without parens — container {} carries zero attrs and zero children. Those shapes are valid; readers sometimes assume otherwise.

Where rejections live

Every error case has a unit test in crates/xrune_nexus/src/tests.rs. The test names start with error_* — they’re a useful catalogue when a real ui! block fails to parse and the message isn’t immediately clear.

The Runes

A reference for the parsed shapes the decipher walk hands a rune. Each type below is exactly what your inscribe_* method receives — read this chapter when writing the body of an inscribe handler and you’re not sure what fields are available.

The shape of DsTree

Everything the parser builds is one type:

pub struct DsTree {
    parent: Option<DsTreeRef>,
    node: DsNode,
    children: Vec<DsTreeRef>,
}
  • parent — set by the parser as it links the tree; backends rarely read it directly. The push/pop idiom in Binding the Rune threads parent identity through the rune, not through this field.
  • node — what kind of casting node this is, see DsNode below.
  • children — sub-trees. Empty for leaf forms; non-empty for widget bodies, control bodies, niche bodies. Match arm children live on the arm, not here.

DsTreeRef is what DsRef mints around DsTree: an Rc<RefCell<DsTree>> newtype. Borrow it with .borrow() / .borrow_mut() like any RefCell. The reference-counted shape is what lets the parser link parents and children without lifetimes; it’s not something the inscribe path generally needs to clone or mutate.

Read access on a borrowed DsTree:

MethodReturnsUse
get_node()&DsNodePattern-match to figure out which kind of node you have.
get_children()&[DsTreeRef]Iterate when you recurse via decipher(child, self).
set_parent(parent)()Parser-only. Backends don’t call this.

DsNode — six variants

pub enum DsNode {
    Root(syn::Expr),
    Widget(DsWidget),
    If(DsIf),
    Iter(DsIter),
    Niche(DsNiche),
    Match(DsMatch),
}

You almost never match on DsNode directly — decipher already dispatches each variant to the right inscribe_* method. The variant names map one-to-one to the trait:

DsNode variantDispatched toReaches the rune as
Root(expr)inscribe_rootparent_expr: &syn::Expr
Widget(w)inscribe_widgetfull widget unpacked into 5 args
If(node)inscribe_ifcondition: &syn::Expr + children
Iter(node)inscribe_iteriterable + variable + children
Niche(node)inscribe_nichename: &syn::Ident + children
Match(node)inscribe_matchscrutinee: &syn::Expr + arms: &[DsMatchArm]

There is no On variant — on EventKind { … } clauses fold into the widget they attach to. They reach the rune as the on_handlers: &[DsOn] slice on inscribe_widget, never as a top-level node.

The peek-side enum DsNodeType is a parser-only thing (Widget / If / Iter / Niche / Match) — same names minus Root. Backends never see it.

DsRoot — the cast envelope

pub struct DsRoot { /* private */ }

impl DsRoot {
    pub fn get_parent(&self) -> syn::Expr;
    pub fn get_content(&self) -> DsTreeRef;
    pub fn get_context_attrs(&self) -> &[DsAttr];
}

You hit DsRoot exactly once per cast — at the host’s macro entry, right before calling inscribe_root and decipher:

let root: xrune::ds_node::DsRoot = syn::parse2(tokens)?;
rune.inscribe_root(&root.get_parent());
decipher(&root.get_content(), &mut rune);
  • get_parent() returns a clone of the parent: value from the :( … :) header, suitable for splicing into emitted code.
  • get_content() returns the body of the cast — the actual DsTreeRef decipher walks.
  • get_context_attrs() returns every attr in the header, including parent itself. Use it when your rune defines extra context keys (world, theme, …) and wants to read them before the walk.

DsRoot also implements Deref<Target = DsTreeRef>, but that’s a parser-side convenience; backends use the explicit getters.

Per-node types

DsWidget

pub struct DsWidget { /* private */ }

impl DsWidget {
    pub fn get_name(&self) -> &syn::Ident;
    pub fn get_attrs(&self) -> &DsAttrs;
    pub fn get_enchants(&self) -> &[syn::Expr];
    pub fn get_on_handlers(&self) -> &[DsOn];
}

inscribe_widget already unpacks all four fields plus the children for you. The DsWidget value itself is what the parser holds; you only reach for it when walking DsNode manually (e.g. in an offline tool like xrune-fmt).

DsAttr and DsAttrs

pub struct DsAttr {
    pub name: Option<syn::Ident>,
    pub value: syn::Expr,
}

impl DsAttr {
    pub fn name_str(&self) -> Option<String>;
}

pub struct DsAttrs {
    pub attrs: Vec<DsAttr>,
}
  • name: Option<syn::Ident> is Some for name: value form, None for positional attrs. name_str() is the matching-friendly version.
  • value: syn::Expr is whatever the user wrote — match on it as you would any syn::Expr, or splice it via quote! into emitted code.

DsOn (event handlers)

pub struct DsOn { /* private */ }

impl DsOn {
    pub fn get_qualifier(&self) -> Option<&syn::Ident>;
    pub fn get_name(&self) -> &syn::Ident;
    pub fn get_args(&self) -> &[syn::Expr];
    pub fn get_body(&self) -> Option<&syn::Block>;
}
  • get_qualifier() is Some(Slider) for on Slider::ValueChanged, None for bare on Tap.
  • get_name() is always present — Tap, ValueChanged, …
  • get_args() is the comma-separated expr list inside (…).
  • get_body() is Some for the { … } body form, None for the callback form (on Tap(cb)); in the callback case the rune typically reads the last element of get_args() as the callable.

DsIf

impl DsIf {
    pub fn get_condition(&self) -> &syn::Expr;
}

Children come from the surrounding DsTree’s get_children().

DsIter (walk … with …)

impl DsIter {
    pub fn get_iterable(&self) -> &syn::Expr;
    pub fn get_variable(&self) -> &syn::Ident;
}

iterable is what comes after walk; variable is the binding after with. The body is again on the surrounding DsTree.

DsNiche (@name { … })

impl DsNiche {
    pub fn get_name(&self) -> &syn::Ident;
}

Single-segment ident only — @foo::bar is a parse error.

DsMatch and DsMatchArm

impl DsMatch {
    pub fn get_scrutinee(&self) -> &syn::Expr;
    pub fn get_arms(&self) -> &[DsMatchArm];
}

impl DsMatchArm {
    pub fn get_pat(&self) -> &syn::Pat;
    pub fn get_children(&self) -> &[DsTreeRef];
}

DsMatch is the only node where children don’t sit on the surrounding DsTree. They’re partitioned across arms — each arm carries its own get_children(). That’s why inscribe_match takes arms: &[DsMatchArm] and the rune writes a two-level loop. See the example in Binding the Rune § Hosting xrune.

Custom keywords

walk, with, and on are registered as syn::custom_keyword! — they cannot be used as widget names, attr names, or any other identifier. The parser dispatches them before the widget peek, so on Foo is not a widget called on.

What you don’t need to know

A few items are public but only matter to the parser or to xrune itself:

  • DsContext / DsContextRef#[allow(dead_code)], an auxiliary structure not on the inscribe path.
  • DsNodeIsMe — peek protocol each node parser implements; only DsNode::what_type() calls into it.
  • DsTreeRef’s inner Rc<RefCell<DsTree>> shape — for decipher to share children across borrows. You won’t normally clone or manipulate the Rc directly from a rune.

Source-of-truth

Everything above is in crates/xrune_nexus/src/ds_node/. One file per type: ds_root.rs, ds_widget.rs, ds_attr.rs, ds_on.rs, ds_if.rs, ds_iter.rs, ds_niche.rs, ds_match.rs, node_enum.rs. The DsRune trait that consumes them is in crates/xrune_nexus/src/ds_rune/mod.rs; the decipher walker beside it.

Binding the Rune

A rune is a backend — an implementation of the DsRune trait. The parser hands you a tree; the rune turns that tree into emitted code.

This chapter walks the trait method by method, then reads the bundled DefaultRune as a worked example.

The trait

DsRune declares seven methods. None has a default implementation — every concrete rune must provide all seven.

pub trait DsRune {
    fn inscribe_root(&mut self, parent_expr: &syn::Expr);

    fn inscribe_widget(
        &mut self,
        name: &syn::Ident,
        attrs: &[DsAttr],
        enchants: &[syn::Expr],
        on_handlers: &[DsOn],
        children: &[DsTreeRef],
    );

    fn inscribe_if(&mut self, condition: &syn::Expr, children: &[DsTreeRef]);

    fn inscribe_iter(
        &mut self,
        iterable: &syn::Expr,
        variable: &syn::Ident,
        children: &[DsTreeRef],
    );

    fn inscribe_niche(&mut self, name: &syn::Ident, children: &[DsTreeRef]);

    fn inscribe_match(&mut self, scrutinee: &syn::Expr, arms: &[DsMatchArm]);

    fn seal(self) -> proc_macro2::TokenStream;
}

How decipher calls them

decipher(tree, &mut rune) walks the AST and dispatches inscribe methods. The crucial detail:

decipher only auto-recurses into the root. Every other inscribe method receives a children slice (or, for inscribe_match, an arms slice where each arm carries its own children) and is responsible for recursing itself.

This is xrune’s most common foot-gun. If your inscribe_widget takes children and forgets to call decipher(child, self), the subtree is silently dropped — no error, no warning, the output just stops mid-tree. Every non-root inscribe method needs that recursion.

That means inscribe_widget typically looks like:

fn inscribe_widget(
    &mut self,
    name: &syn::Ident,
    attrs: &[DsAttr],
    enchants: &[syn::Expr],
    on_handlers: &[DsOn],
    children: &[DsTreeRef],
) {
    /* … emit widget construction code for `name`, `attrs`, etc. … */

    for child in children {
        decipher(child, self);   // omit this and the subtree disappears
    }

    /* … emit any post-children fixup … */
}

inscribe_if, inscribe_iter, and inscribe_niche follow the same single-loop shape.

inscribe_match is the exception. Its signature takes arms: &[DsMatchArm] only — there is no separate children slice. Each arm carries its own get_children(), so the rune writes a two-level loop: outer over arms, inner over each arm’s subtree.

fn inscribe_match(&mut self, scrutinee: &syn::Expr, arms: &[DsMatchArm]) {
    /* … emit match header … */

    for arm in arms {
        /* … emit this arm's pattern header … */
        for child in arm.get_children() {
            decipher(child, self);
        }
        /* … emit this arm's footer … */
    }
}

The rune drives depth; decipher dispatches one level at a time. This is intentional: it gives the rune full control over order (emit parent before children, both interleaved, or only after the whole subtree is known) and scope (push a parent symbol onto a stack before recursing, pop it after).

The sealing pattern

seal(self) -> TokenStream consumes the rune by value at the very end. Everything inscribed during the walk gets accumulated into the rune’s internal state — typically a proc_macro2::TokenStream field — and seal returns it.

struct MyRune {
    out: proc_macro2::TokenStream,
}

impl DsRune for MyRune {
    /* … inscribe_* methods append to self.out via quote! { … } … */

    fn seal(self) -> proc_macro2::TokenStream {
        self.out
    }
}

seal taking self by value is deliberate — it makes the finalisation single-shot. A rune that wants to inspect or post-process its accumulated state runs that logic inside seal.

The name “seal” is the trait method, not Rust’s sealed-trait pattern. Same word, unrelated meaning.

The parent-context idiom

Backends commonly need to know which widget the current child is being spawned under. The convention DefaultRune uses — and the convention real ECS-shaped runes lean on — is save → set → recurse → restore:

fn inscribe_widget(&mut self, name: &syn::Ident, /* … */ children: &[DsTreeRef]) {
    let name_string = name.to_string();           // 1. syn::Ident → String
    let prev_parent = self.parent_name.clone();   // 2. save the current parent
    self.parent_name = name_string;               // 3. become the parent for this subtree

    /* … emit code referring to `self.parent_name` … */

    for child in children {                       // 4. children see *me* as parent
        decipher(child, self);
    }

    self.parent_name = prev_parent;               // 5. restore so my next sibling sees the right parent
}

The shape threads parent identity through arbitrary nesting without touching globals.

Worked example: DefaultRune

The bundled reference rune lives in crates/xrune/src/default_rune.rs and is the cleanest existing implementation of the seven methods. Read it end-to-end — every inscribe handler is a few lines, the parent push/pop idiom is in plain sight, and seal returns the accumulated println!-shaped TokenStream.

There are two identical copies of DefaultRune in the repo:

  • xrune::default_rune::DefaultRune — public, documented, what you read when starting your own rune.
  • A private copy inside xrune-incant — what ui! { … } actually expands against.

Why two? Because xrune-incant is a proc-macro crate, and Rust forbids any non-macro item it exposes from being imported by a downstream crate. Even if its DefaultRune were marked pub, xrune::default_rune::DefaultRune = xrune_incant::DefaultRune is rejected by the compiler:

error[E0432]: unresolved import `xrune_incant::DefaultRune`
  no `DefaultRune` in the root

So xrune-incant keeps a private copy for its own ui! expansion, and xrune writes a separate, public, copy-able-from copy in its default_rune module for readers. Both copies are byte-for-byte the same logic; only their visibility differs.

(A theoretical fix would be to sink default_rune into xrune-nexus and have both crates import it, but that drags backend code — and the quote dependency it brings — into a core that is deliberately kept to just AST + DsRune trait + decipher.)

What ui! { … } actually does

The proc-macro is not an extension point. Its body, in full:

#[proc_macro]
pub fn ui(input: TokenStream) -> TokenStream {
    let root = parse_macro_input!(input as DsRoot);
    let mut rune = DefaultRune::new();        // ← hard-coded
    rune.inscribe_root(&root.get_parent());
    decipher(&root.get_content(), &mut rune);
    TokenStream::from(rune.seal())
}

That DefaultRune::new() is literally hard-wired. There is no way for the caller of ui! to swap it for something else. So when you write ui! { … } in your application, what gets pasted back into your source is the private println-shaped TokenStream DefaultRune emits. Nothing more.

Concretely: ui! is a demonstration that the parser → decipherseal pipeline works end-to-end. It is not the entry point for a real backend.

Hosting xrune in your own crate

For a real backend you don’t call ui!. You build your own proc-macro crate, paste the same five-line hosting boilerplate, and swap in your own rune. The shape:

my-host-incant/Cargo.toml

[package]
name = "my-host-incant"
edition = "2024"

[lib]
proc-macro = true

[dependencies]
xrune = "1.5"
syn = "2"
proc-macro2 = "1"
quote = "1"

xrune is the umbrella crate that re-exports xrune-nexus (the parser, the DsRune trait, the decipher walker) and exposes xrune::default_rune::DefaultRune as a copy-able reference rune. You could depend on xrune-nexus directly to avoid the umbrella, but xrune gives you shorter paths and lets you reach the public DefaultRune while you’re prototyping.

my-host-incant/src/lib.rs

use proc_macro::TokenStream;
use syn::parse_macro_input;
use xrune::ds_node::DsRoot;
use xrune::ds_rune::DsRune;
use xrune::ds_rune::decipher::decipher;

mod my_rune;
use my_rune::MyRune;

#[proc_macro]
pub fn my_ui(input: TokenStream) -> TokenStream {
    let root = parse_macro_input!(input as DsRoot);
    let mut rune = MyRune::new();
    rune.inscribe_root(&root.get_parent());
    decipher(&root.get_content(), &mut rune);
    TokenStream::from(rune.seal())
}

Pick your own macro name (my_ui, bevy_ui, whatever). Inside my_rune.rs you implement the seven DsRune methods, emitting the real spawn / render / whatever code your host actually needs. While prototyping you can swap MyRune::new() for xrune::default_rune::DefaultRune::new() to get the println trace through your own macro and verify the wiring.

Downstream users then write:

use my_host_incant::my_ui;

my_ui! {
    :(
        parent: world
    :)
    /* … exact same casting syntax xrune accepts … */
}

Same DSL, your code-gen.

The xrune-fmt formatter is a sibling consumer that goes through the same parser but doesn’t implement DsRune — it walks DsTree directly to re-print. That’s the third shape of consumer (offline tool), if your goal is analysis or reformatting rather than emitting runtime code.

Where to go next

  • Compare DefaultRune with the formatter in The Scribe — both walk the same tree shape, but only one implements DsRune. The contrast is informative.
  • Read The Codex of Changes for the cross-version drift of the trait surface.

The Scribe

xrune-fmt is the formatter for ui! { … } blocks. It’s a CLI binary, not a library — install it once and point it at any .rs file that contains casting macros.

cargo install xrune-fmt

xrune-fmt src/app.rs            # rewrite in place
xrune-fmt src/app.rs --check    # exit 1 if not formatted, leave file alone

What it does

For every ui! { … } block it finds, the scribe:

  1. Locates the macro by regex (ui!\s*\{) and the matching } via brace-depth counting.
  2. Hands the inside to the real parserxrune-nexus’s DsRoot::parse — and gets back a DsTree.
  3. Walks the tree and re-emits it with consistent indentation, line breaks, and spacing.
  4. If the parser rejects the input, the original block is left untouched. The scribe never silently rewrites a block it cannot understand.

It only touches the body of ui! { … }. Code around the macro is preserved byte-for-byte.

Formatting rules

  • Context header always multi-line, one attr per line, indented one step beyond the ui! brace.
  • Widget attrs ride on a single line if they fit within MAX_LINE_WIDTH = 100; otherwise each attr goes on its own line. If the original was multi-line, the scribe keeps it multi-line even when it would now fit on one — author intent wins over column count.
  • Attribute values, walk iterables, if/match scrutinees, and on bodies all run through prettyplease so embedded Rust expressions render in canonical form.
  • on EventKind clauses stack between attrs and body; their bodies are indented one step beyond the surrounding widget.
  • Enchants sit between attrs and body in [ … ], comma-separated.

The scribe round-trips the same forms xrune-nexus::tests exercises — positional attrs, named attrs, headerless widgets, niches, match arms, the three on shapes (Form B, Form C, callback). The xrune-fmt test suite walks every fixture in examples/example0/src/ui to confirm idempotence.

Why a separate parser-shaped consumer

The scribe is the second consumer of the same DsTree. The first is your rune (DsRune impls + decipher). The scribe doesn’t implement DsRune — it walks the tree manually, because its goal is to re-emit syntax rather than transform a tree into runtime code.

This is also why the scribe is the canary for the language: any time the parser learns a new shape, the scribe needs the same field exposed; any time the AST grows a new node, the scribe needs a new arm. If you’re considering adding to xrune-nexus, glance at the scribe to see how much downstream work the addition implies.

Source-of-truth

The Workshop

How the workspace is laid out, and what cargo xtask gives a maintainer. This chapter is for contributors. End users only need The First Incantation and Binding the Rune.

The five crates and one xtask

xrune/
├─ crates/
│  ├─ xrune-sigil      ← derive macro: `#[derive(DsRef)]`
│  ├─ xrune-nexus      ← AST + DsRune trait + decipher
│  ├─ xrune-incant     ← proc-macro: `ui! { … }`
│  ├─ xrune            ← umbrella: re-exports nexus + ui!, plus default_rune
│  └─ xrune-fmt        ← CLI binary: the scribe
├─ examples/
│  └─ example0         ← canonical hello-world fixture
└─ xtask/              ← CI/build/test/lint/doctest/bump/publish/release

All five published crates live on one workspace and ship one moving version. cargo install xrune and you also get xrune-incant and xrune-nexus linked in transitively.

xtask is publish = false — it never reaches crates.io. It exists only to drive the workspace itself.

cargo xtask commands

cargo xtask <ci|build|test|lint|doctest|bump <level>|publish [--dry-run]|release>
  • cibuild + test + lint + doctest, in that order. This is what .github/workflows/ci.yml runs. Locally green = CI green.
  • buildcargo build --workspace.
  • testcargo test --workspace.
  • lintcargo +stable clippy --workspace -- -D warnings, followed by cargo +stable fmt --all --check. The +stable toolchain pin matches CI.
  • doctest — extract every ```rust block from docs/src/ and docs/src-zh-CN/ into a throwaway test crate at docs/test/ and cargo build --tests. Catches drift between docs and reality.
  • bump <major|minor|patch> — rewrite the version in every Cargo.toml (workspace + the five version = "=X.Y.Z" pins under [workspace.dependencies]). Hand-editing the version is forbidden.
  • publish [--dry-run] — push to crates.io in fixed order: xrune-sigil → xrune-nexus → xrune-incant → xrune → xrune-fmt, with a 30-second sleep between to let the index settle. Already- uploaded crates are skipped. Don’t run this directly; use release.
  • release — the only path to a real release. Requires a clean tree, then internally: push main → wait for CI green via gh run list (10-minute timeout) → tag vX.Y.Z → push the tag → gh release create --generate-notes → run publish.

Workspace conventions

  • [workspace.package].version is the single source of truth. [workspace.dependencies] pins each internal crate with version = "=X.Y.Z" so all five always march in lock-step.
  • Cargo.lock is gitignored — this is a deliberate choice for the workspace, not an oversight.
  • default-members = ["crates/*"]cargo build from the root builds the published crates, not xtask or examples/.
  • edition = "2024" workspace-wide.
  • resolver = "2" for cargo’s modern feature unification.

Source-of-truth

The Codex of Changes

A pointer to the version history rather than a full retelling. The authoritative changelog is CHANGELOG.md; this chapter highlights only the shifts that affect a rune you’ve already written.

What’s currently alive

The shape every other chapter documents — the DsRune trait with its seven methods, the six DsNode variants, the three on forms (Form B, Form C, callback-form), enchants, niches, match arms, the name: Option<syn::Ident> attr shape — is the shape that ships today on crates.io. Code written against the current docs compiles against the current crates.

Migration notes by surface

If you have an existing rune from an earlier release, here’s what’s changed underneath you. Each entry assumes you’re on the previous shape and need to move to the current one.

on EventKind handlers

Earlier the parser had a top-level DsOn node that sat next to widget / if / iter / niche / match in DsTree. The trait had a matching inscribe_on method.

The current shape has neither. on clauses fold into the widget they attach to, reaching the rune as on_handlers: &[DsOn] on inscribe_widget. There is no DsNode::On variant and no inscribe_on method.

If you had an inscribe_on impl: delete it, and read on-handlers from the on_handlers slice inside inscribe_widget instead.

DsOn::get_body()

Previously returned &syn::Block directly. Now returns Option<&syn::Block> because on EventKind(cb) (callback-form) carries no body.

If you matched on the body unconditionally, switch to handling the None case — typically by reading the trailing element of get_args() as the callback expression.

DsAttr::name

Previously a non-optional syn::Ident. Now Option<syn::Ident> because positional attrs (text("hello")) carry no name.

If you read attr.name directly, match on the Option. The convenience name_str() -> Option<String> is the matching-friendly form.

Niche and match nodes

@name { … } and match expr { … } are AST nodes the trait now includes. If your DsRune impl predates them, the compiler will demand inscribe_niche and inscribe_match — the trait has no default impls.

Removed

These items are not in the language any more. If you find them in example code, it’s pre-current.

RemovedReplaced by
DsRune::inscribe_on methodon_handlers: &[DsOn] on inscribe_widget
DsNode::On variantfolded into the widget
DsTreeToTokens traitthe DsRune codegen interface
ds_traverse modulexrune::ds_rune::decipher::decipher
Crate names xwrapup / xrune_derive / xrune_parser / xrune_macrosxrune-sigil / xrune-nexus / xrune-incant / xrune

Source-of-truth

Comparisons

xrune sits in a crowded neighbourhood of Rust DSL and UI macros. The quick way to find your bearings:

xrunetyped-builder DSLs (e.g. leptos::view!, dioxus::rsx!, yew::html!)Macro-by-example UI helpers (maud, html!)
Validation timingAt rune time. The DSL itself accepts any ident as widget, any syn::Expr as attr value.At parse time. Widget names are concrete types; attrs map to typed setters.Mostly compile-time, but tied to a fixed grammar (HTML).
Widget vocabularyOpen. Whatever your rune chooses to honour.Closed. The host crate’s component library defines what’s valid.Closed. Hardcoded to a single output (HTML).
Backends per syntaxMany. One DSL → many runes (renderer, ECS, formatter, analyzer, …).One. The DSL and the framework are fused.One.
Where you write your code-genA DsRune impl in your own crate.Comes with the framework.Inside the macro itself.
Type checking on attr valuesNone at parse time; the rune decides.Strong — attrs become typed builder calls.Limited to what the macro can pattern-match.
Iteration / conditional / matchFirst-class DSL nodes (walk, if, match).Usually delegated to inline Rust expressions inside the macro.Inline Rust.

When to reach for xrune

  • Your host already owns its component / state / render model and you want a casting layer above it without committing the syntax to that model.
  • The rendering target isn’t decided yet, but the surface syntax is.
  • Multiple consumers want to share one syntax — e.g. an ECS-runtime rune and a pretty-printer and a static analyzer.

When to look elsewhere

  • You want compile-time type-checking on widget names and attribute values — reach for a typed-builder DSL (leptos, dioxus, yew, typed-builder directly). xrune deliberately punts all type-level validation to the rune.
  • Your output is plain HTML and you don’t care about pluggable backends — maud or html! are tighter for that single goal.
  • You want a ready-made widget library — xrune ships none. The runes in the wild bring their own.

A loose mental model

Typed-builder DSLs lock the surface to one back-end and give you safety in exchange. xrune does the opposite — it locks the shape of casting (one parser, one tree, one walk) and lets the back-end be anything you can fit through the seven-method DsRune trait.

Both are valid trade-offs. xrune is the right tool when “what does a widget actually do?” is itself a moving question.

Appendix · The Lexicon

This is the contract between xrune’s medieval vocabulary and ordinary compiler/CS terms. Every chapter reaches back here when the metaphor risks drifting from the engineering meaning.

Naming compact

xrune termPlain meaning
sigilA derive macro (xrune-sigil). The DsRef derive mints {Name}Ref newtypes around Rc<RefCell<Name>>.
nexusThe core crate (xrune-nexus) — AST node types, the DsRune trait, and the decipher walker. The hub everything else binds to.
incantThe proc-macro crate (xrune-incant) that exposes ui! { … }. The act of invoking the DSL.
runeA backend implementation of the DsRune trait — turns the parsed tree into emitted code. The DSL is one casting; runes are many translations.
decipherThe free function xrune::ds_rune::decipher::decipher(tree, &mut rune) that walks a DsTree and dispatches one inscribe call per node.
inscribeOne method on DsRuneinscribe_root / inscribe_widget / inscribe_if / inscribe_iter / inscribe_niche / inscribe_match. Each receives a node and accumulates output into the rune.
sealThe trait method seal(self) -> TokenStream. Consumes the rune at the end of decipher to produce the final emitted code. Not Rust’s “sealed trait” pattern — same name, unrelated meaning.
enchantA bracketed expression list [expr, expr, …] attached to a widget. Arbitrary data the rune can attach to a node — typically ECS components or attached state.
nicheA @name { … } node. An anchor / slot / named region routed by the host rune (e.g. portals, named ports).
walk … with …Iteration. walk iterable with var { … } is xrune’s for loop.
onAn event handler clause attached to a widget — on EventKind { … } (body form) or on EventKind(cb) (callback form).
scribeThe formatter binary xrune-fmt — re-renders DSL inside ui! { … } blocks.
grimoireThis documentation site.
codexThe version history (CHANGELOG).

DSL casting compact

FormReads as
:( ... :) (each attr on its own line)Context area. parent is required; other keys are rune-defined (world, theme, …).
Widget (k: v) { … }A widget node with named attrs and children.
Widget (Text("hi"))A widget with a positional attr. No body required.
Widget (k: v) [Comp{…}, Tag] { … }Same, with enchants between attrs and body.
if expr { … }Conditional render.
walk it with x { … }Iteration.
@slot { … }Niche (named anchor).
match e { Pat => { … } … }Pattern match across sub-trees.
Widget () {} on Tap { fire() }Form B: on after the body, attaches to the preceding sibling.
Widget () on Tap { … } { … }Form C: on between attrs and body, attaches to this widget.
on Tap(cb)Callback form. cb is the trailing arg; rune decides what it means.

Public API index

The shape every reader needs once:

CratePublic surface
xrune-sigil#[derive(DsRef)]
xrune-nexusds_node::* (the Ds* AST types) · ds_rune::DsRune trait · ds_rune::decipher::decipher · pub use xrune_sigil::DsRef
xrune-incant#[proc_macro] pub fn ui
xrunepub mod default_rune; · pub use xrune_incant::ui; · pub use xrune_nexus::*;
xrune-fmtxrune-fmt <file.rs> [--check] (binary)