Writing plugins¶
Lumen has four plugin kinds. Runtime plugins join a running app (systems,
resources, script functions) and are compiled into it. Runtime
modules are the same plugins built as prebuilt shared
libraries an app declares in lumen.toml and the engine loads at startup.
Portable plugins are prebuilt libraries too, declared in
the same table, but speak a serialized C ABI instead of linking the engine:
narrower reach, no version lock to one engine build. Compiler
plugins change what lumenc produces from an app's
sources. The first part of this page covers runtime plugins; the other kinds
have their own sections at the end.
A runtime plugin is how Rust code joins a Lumen app. It registers systems into the tick and render schedules, installs resources, and can replace parts of the frame pipeline. Every backend in the workspace is a plugin, so the extension surface you get is the one the framework uses on itself.
Read Architecture first for the two-world model and the stage ordering; this page assumes both.
The trait¶
The trait lives in lumen-core:
pub trait Plugin: Sized {
fn name(&self) -> &'static str { /* defaults to the type name */ }
fn depends_on(&self) -> &'static [&'static str] { &[] }
fn build(self, app: &mut App);
fn cleanup(&mut self, _app: &mut App) {}
}
build takes self by value. That is deliberate: a plugin can carry a payload
that cannot be cloned, such as a text shaper, a GPU device, or an async
runtime, and move it into the world. It also means a plugin instance is
installed exactly once.
cleanup is declared but nothing calls it today; do not rely on it for
teardown. Release resources from a Drop implementation on whatever you insert
into the world instead.
depends_on names plugins that must already be installed. Installation checks
each name against the installed set and prints a warning when one is missing;
it does not defer, reorder, or fail. Order your installs correctly and treat a
warning as a bug in the install order.
A minimal plugin¶
use bevy_ecs::prelude::*;
use lumen_core::prelude::*;
/// Counts entities that gained focus.
#[derive(Resource, Default)]
pub struct FocusCount(pub u64);
pub struct FocusCountPlugin;
impl Plugin for FocusCountPlugin {
fn name(&self) -> &'static str {
"FocusCountPlugin"
}
fn build(self, app: &mut App) {
app.world.init_resource::<FocusCount>();
app.add_systems(TickStage::Systems, count_focus);
}
}
fn count_focus(mut count: ResMut<FocusCount>, q: Query<Entity, Added<Focused>>) {
count.0 += q.iter().count() as u64;
}
Install it on an App before the run loop starts:
Give name an explicit value when the plugin is part of a public surface. The
default is the Rust type path, which changes whenever the module moves, and
depends_on matches on the string.
Carrying configuration¶
A plugin can hold tunables and turn them into a resource in build. The press
recognizer does exactly that: the plugin exposes a long-press threshold and a
double-click window, Default supplies platform-conventional values, and
build moves them into a config resource that systems re-read every frame, so
an app can change them at runtime.
Registration surface¶
App exposes these:
add_systems(TickStage, systems)adds main-world systems to one of the five tick stages.add_render_systems(RenderStage, systems)adds render-world systems toPrepareorRender.add_extract_systems(ExtractSet, systems)adds render-world systems that run between extract and render, for work that reads what extract just produced.add_extract_fn(f)appends a cross-world extract function.add_message::<M>()registers a message type so writers and readers work for it.register_command::<T, _>(handler)registers a handler for a typed command, which is how off-thread code reaches the world.request_threads_at_least(n)raises the worker budget. It is monotonic across plugins and takes effect at the first tick.is_plugin_added::<P>()andplugin_added(name)answer whether something is already installed, for a plugin that must not double-register.
The default stack that a full app gets is assembled in lumen-runtime, which
is the place to look for what is already registered and in what order.
Ordering¶
The tick stages are the coarse ordering: input, then command drain, then application systems, then layout, then accessibility. Within a stage, systems run in parallel unless you say otherwise, and two systems that touch the same data in conflicting ways get serialised in whatever order the executor picks. That order can flip between ticks.
So state the edges you depend on. The scroll primitive orders every writer of the scroll offset before the hit test, because without the edge a fling that landed after the hit test left hover markers reflecting pre-scroll positions under a stationary cursor. The hover and press paint systems order themselves after the input dispatch for the same reason: otherwise the visual trails the state by a frame.
Order against public system functions from the crate that owns them, not against your own guesses about stage packing.
Resources¶
Insert plain resources with insert_resource, or init_resource when
Default is right. Prefer init_resource and a presence check when another
plugin may have installed the same resource; several plugins in the tree do
this so install order stays flexible.
Types that are not Send go in through insert_non_send and come out through
NonSendMut. This is not a preference, it is a requirement for several of the
backends: the taffy layout tree stores a raw pointer inside its compact length
representation and is neither Send nor Sync, and GPU device handles are
non-send on some platforms. A non-send resource pins its systems to the main
thread, which is the intended trade.
The text shaper is one of these. It lives in ShaperService, and a plugin that
brings its own shaper installs it with insert_non_send. Layout measures text
through whatever is installed, so the swap covers sizing as well as drawing.
Viewport exists in both worlds. If your plugin resizes or reconfigures the
surface, write both copies; layout reads the main-world one and the renderer
reads the render-world one.
RunMode says whether this run has an interactive session, and is in the
world before the first plugin is installed. Read it with app.is_headless()
from build when your plugin would otherwise hold something that only makes
sense for a person at a window: an output device, a daemon, a thread whose job
is waking the event loop.
fn build(self, app: &mut App) {
let device = if app.is_headless() { None } else { open_device() };
}
An embedder that assembles its own App may never insert the resource, so
absence reads as an interactive run; that is the safe default for whatever is
being suppressed.
Render-world plugins¶
A render backend inserts itself into app.render_world, registers a system
in RenderStage::Render, and calls install_extract_pipeline so the extract
step that feeds it exists at all. The whole of the software rasterizer's
plugin is that call, a resource insert and one system registration; the GPU
backend adds a fragment cache and an optional text shaper alongside. The
install is a no-op when another backend already made it, so a test that builds
a bare App and reads the render world makes the same call itself.
Getting data across is the extract step. An extract function is a plain
function pointer, fn(&mut World, &mut World), not a closure, so any state it
needs lives in a render-world resource. It takes the main world mutably only
because building a query caches component ids on the world; nothing about the
app's state is meant to change there.
Adding a new drawable primitive is three pieces: an extracted component, an extract function that produces it, and a render system that consumes it. Replacing an existing entry in the chain, rather than appending, is how a plugin changes what every primitive sees.
Painting your own pixels¶
A plugin whose content the built-in primitives cannot describe, such as a
chart, a map, or a drawing surface, paints it itself. The scene graph
positions, orders, and clips an opaque leaf, and what goes inside that leaf is
between the plugin and the render backend. Qt calls the same arrangement
QSGRenderNode and GTK calls it GskGLShaderNode.
It takes four moves.
A main-world component holds the state you draw from, like any other plugin state.
An extract function turns that state into an ExtractedNative in the
render world. Place each leaf with NativeExtract: it resolves the paint order
for the entity's position in the document, subtracts the scroll offset of every
ancestor, reports the opacity the entity inherits, and returns nothing at all
for an entity that is hidden or scrolled out of its container. Placing a leaf
from Transform::absolute by hand instead pins it in place while its scroll
container scrolls. Hand the finished leaves to upsert_native_leaves, which
keeps one render-world entity per leaf across frames and retires the ones that
went away. Its bookkeeping is scoped to your extension id, so two plugins
extracting in the same frame never evict each other's leaves.
A painter registered for the same extension id draws the leaf when its turn comes:
A painter receives its draw target as &mut dyn Any and downcasts it to the
backend's scene type, which for the wgpu backend is a vello::Scene. That
downcast is a TypeId match, and two builds of the same vello version are two
different types as far as TypeId is concerned: a crate that declared vello
itself would compile, register, and then quietly paint nothing. Take it from
the one place that cannot drift - lumen_render_wgpu::vello, re-exported by
the module SDK behind its off-by-default paint feature:
A system that raises FrameDirty when your state changes. Nothing else
will: the dirty roll-up watches the framework's own components, not yours, so
a tick that only changed your state is a clean tick and extract never runs. If
the content animates, raise AnimationsActive while it is still moving.
Together:
use lumen_core::prelude::*;
use std::sync::Arc;
#[derive(Component)]
struct Sparkline {
samples: Vec<f32>,
revision: u64,
}
impl Sparkline {
/// Every content change takes a new stamp. Without one the frame diff reads
/// the leaf as unchanged and the new samples never reach the screen.
fn push(&mut self, sample: f32) {
self.samples.push(sample);
self.revision = next_revision();
}
}
/// What the painter is handed. The extension id promises this type.
struct SparklinePayload {
samples: Vec<f32>,
opacity: f32,
}
struct SparklinePainter;
impl NativePainter for SparklinePainter {
fn paint(&self, ctx: &mut NativePaintCtx<'_>) {
let Some(payload) = ctx.payload_as::<SparklinePayload>() else {
return;
};
// Draw into the backend's target, in logical coordinates placed by
// `ctx.device_transform()`. On lumen-render-wgpu the target is a
// `vello::Scene`, reached through that crate's `vello` re-export.
let _ = (payload, ctx.bounds, ctx.opacity);
}
}
fn extract_sparklines(main: &mut World, render: &mut World) {
let mut place = NativeExtract::new(main);
let mut q = main.query::<(Entity, &Transform, &Sparkline, Option<&Opacity>)>();
let leaves: Vec<(Entity, ExtractedNative)> = q
.iter(main)
.filter_map(|(e, transform, sparkline, opacity)| {
let placed = place.place(e, transform, opacity)?;
Some((
e,
ExtractedNative {
extension_id: "acme.sparkline".into(),
payload: Arc::new(SparklinePayload {
samples: sparkline.samples.clone(),
opacity: placed.opacity,
}),
bounds: placed.bounds,
order: placed.order,
revision: sparkline.revision,
clip_to_bounds: true,
},
))
})
.collect();
upsert_native_leaves(render, "acme.sparkline", leaves);
}
fn redraw_when_samples_change(
changed: Query<(), Changed<Sparkline>>,
mut frame_dirty: ResMut<FrameDirty>,
) {
if !changed.is_empty() {
frame_dirty.dirty = true;
}
}
The contracts that come with the seam:
- Bounds enclose the paint. They must cover every pixel the painter touches,
and they must have area. Damage is computed from them, so paint outside them
stays on screen as a stale smear until something else repaints that region,
and a leaf that declares zero width or height falls back to repainting the
whole viewport because an empty rect is no damage at all.
clip_to_boundsenforces the first half of the promise for you, at the cost of a clip layer. - The revision is pixel identity. Two leaves with the same extension id,
bounds, clip flag, and revision are taken to be the same pixels and cost no
repaint. Give the leaf a new revision, from
next_revision(), whenever its content changes, or the frame never repaints. The payload is not compared: producers rebuild it every dirty frame, so comparing it would report a change every time. - A painter takes
&self. One painter serves every leaf carrying its extension id, so per-frame state goes behind interior mutability. - The extension id names a contract, covering the payload type and what the painter expects of the backend. A backend with no painter for an id skips that leaf in silence, which is what lets one scene render on a backend that does not implement the extension.
- Opacity is the painter's to apply. A bounds clip composites nothing, so
asking to be clipped never changes a leaf's alpha.
ctx.opacitycarries what ancestor opacity groups accumulated; the entity's own CSSopacityreaches you asNativePlacement::opacityat extract, to fold into the payload the way the built-in extractors fold it into their colours. - Leave the layer stack as you found it. The walker closes any layer a painter left open before it moves on, so a painter cannot cost the rest of the frame its clips, but a painter that pops more than it pushed has already closed someone else's.
Paint order always comes from the placement. Leaves that share an order key sort by extension id and then by leaf, so what a frame paints on top does not change between frames.
Which backends paint these leaves:
| Backend | Native leaves |
|---|---|
lumen-render-wgpu |
Painted. The draw target is a vello::Scene, reached through that crate's vello re-export so a painter cannot version-skew its downcast. BACKEND_ID names the backend in the paint context. |
lumen-render-headless |
Not painted. It rasterises the extracted rects directly and never walks the node tree. |
lumen-web-dom |
Not painted. The web target emits DOM nodes rather than walking the retained tree. |
The paint context carries no text shaper. Draw text through a sibling text element, or shape it yourself before extract.
Two rules apply to anything on this path:
- Iterate deterministically. Paint order comes from document order, z-index, and entity identity. Never let it fall out of archetype iteration order: adding and removing hover and press markers moves entities between archetypes, and painter order would shuffle with them.
- Respect the dirty flag. The tick skips extract and render entirely when nothing render-relevant changed. If your plugin produces visible change through a path the roll-up does not observe, raise the flag; if it animates, raise the per-tick animation flag while it still has motion and stop raising it the moment it settles. A driver that raises the flag unconditionally keeps the window redrawing forever.
Visual constants belong in CSS¶
A plugin does not hardcode how things look. Colours, metrics, and timings must be reachable from CSS or a design token, and Rust holds at most one fallback for the case where the author specified nothing.
The state tweens are the pattern to copy. Each has a built-in duration
constant, but the systems read the entity's transition specification first and
fall back to the constant only when CSS said nothing, so
transition: background-color ... overrides both the duration and the easing
curve. Scrollbar colours and metrics work the same way, resolving from
scrollbar-color and scrollbar-width with a defaulted style component
behind them.
Where a value is a per-tag default rather than a per-widget one, it goes in the
user-agent stylesheet in lumen-runtime instead of in Rust. That sheet applies
to every app beneath any skin and beneath the app's own CSS, so an author
overrides it with an ordinary rule. Only defaults that CSS cannot express, such
as one that applies conditionally on another property being unset, stay in
Rust.
Constraints to respect¶
- Do not add backend dependencies to
lumen-core. If your plugin needs a type in core to talk about, add the type, not the dependency. That is why the renderer, layout, window, spawner, and timer roles are traits there and the concrete crates live outside. - Reach a backend through its service resource, not its crate. Text goes
through
ShaperService, async work throughSpawnServiceandTimerService. A plugin that names an implementation crate to get at one has chosen the app's backend for it, and an app that swaps that backend then runs two of them. - Provide an alternative. A backend trait needs at least one default implementation and one other path, so removing the default does not break the build. A headless or stub implementation counts.
- Never let a panic cross the C ABI. If your plugin is reachable from the C ABI, its failure has to become a status code, not an unwind through a foreign frame.
- Keep the core stack ungated. Layout, input, text editing, and the interaction primitives are always installed. Gating a subsystem on a usage scan is fine only when a false negative is impossible; when in doubt, install it and let it idle.
Optional subsystems¶
A subsystem the runtime can do without is a capability. Its crate exposes an
install entry, a crate beside it holding nothing else registers that entry
with lumen_capability!, and the run loop installs whatever capabilities the
binary carries without naming any of them.
// In the subsystem's crate, `pub mod capability`:
use lumen_capability::CapabilityEnv;
use lumen_core::app::App;
pub fn install(app: &mut App, env: &CapabilityEnv) {
// Skip the manager for an app that provably never asks for one. The
// query answers yes whenever the sources cannot be read in full.
if !env.sources_mention(&["register_hotkey"]) {
return;
}
app.world.insert_non_send(HotkeyManager::new());
app.add_systems(TickStage::Systems, poll_hotkeys);
}
// The whole of the `lumen-os-hotkey-capability` crate:
use lumen_capability::{Phase, lumen_capability};
lumen_capability!("os-hotkey", Phase::Platform, lumen_os_hotkey::capability::install);
The registration is a crate of its own because a crate is one object to the linker. The constructor the macro emits is a root nothing can strip once its object is linked, and the subsystem's own object gets linked for reasons other than the constructor: a generic instantiated there and reused by a crate compiled later. In an object of its own the constructor is reached by its register symbol and by nothing else.
select says when a static package carries the capability: Select::Always,
Select::OnUse(&[...]) for an app whose sources mention one of the listed
names, or Select::OnRequest for a development subsystem an app has to name
in [capabilities]. The phase says where in the build it runs: Platform after the core stack
and before the reactive bindings, BeforeScripts ahead of the script hosts
(for something a host binds to at construction, such as the HTTP client),
AfterBuild once the document is spawned and styled (an overlay). Within a
phase, capabilities install in name order. The environment carries the app
directory and id, the run mode, the whole lumen.toml for the capability to
read its own section from with section, and a bounded view of the sources
for sources_mention. A capability that has to act before the app is built
adds preflight = <fn>; a single-instance app's second launch exits from
there.
A capability answers its own script commands: it reads the command stream
through a cursor of its own and applies the variants it owns, so nothing in
the runtime has to know it exists. The register symbol the macro exports is
what a link selects the capability by; a plain cargo build links every
capability in the graph, and the same mechanism the runtime modules use lets
a replayed link leave one out.
Custom tags¶
A plugin that adds a markup tag registers it with the widget registry, which
the markup parser consults after its built-in tag table misses.
#[derive(Widget)] generates that registration together with a companion
plugin, so installing the plugin is enough. Registration has to happen before
the app's markup is parsed. Suppressing the generated plugin also suppresses
the registration, in which case call the generated register function
yourself.
A runtime module that brings a tag has the same job and
one more problem: a compile loads no module. lumenc build, lumenc check,
and lumenc package run the parser on a machine where nothing was opened, so
the element would be refused however diligently the module registers it. The
app states the claim instead:
Do both. The module registers its tags from Plugin::build
(lumen_module::lumen_widget::register_widget_tag_owned), which is what makes
a run accept them; the key is what makes a build accept them. Modules install
before the markup is parsed precisely so the first half works, which means an
app that forgot the key runs and then fails to build - so a module's
documentation says to write it. A tag the language already owns is refused
where it is declared, because a module reinterpreting <button> would be
settled by load order rather than by anything an author wrote.
Exposing functions to scripts¶
A plugin can give the app's script functions of its own. One description covers every language: describe the function once and a Rhai, Lua, or candela script calls it.
The short form takes a plain Rust closure and reads the signature off its types:
use lumen_core::prelude::*;
use lumen_script::{ScriptFn, ScriptFnAppExt, ScriptNs};
pub struct GpioPlugin;
impl Plugin for GpioPlugin {
fn build(self, app: &mut App) {
app.add_script_fn(
ScriptFn::from_fn("level", |pin: i64| -> Result<i64, String> {
match pin {
0..=27 => Ok(read_pin(pin)),
_ => Err(format!("pin {pin} is out of range")),
}
})
.param_names(["pin"])
.with_ns(ScriptNs::Named("gpio".to_string())),
);
}
}
i64, f64, bool, String, Vec<T>, HashMap<String, T> and () are the
types that cross, up to eight arguments. The declared return type is the value
type either way, so the same declaration binds a closure that can fail and one
that cannot.
The builder form spells the signature out, and is what you want for a doc line, optional trailing arguments, a restricted set of languages, or a body that queues script commands:
use lumen_script::{ScriptFn, ScriptNs, ScriptTy, ScriptValue};
ScriptFn::new("level")
.ns(ScriptNs::Named("gpio".to_string()))
.param("pin", ScriptTy::Int)
.ret(ScriptTy::Int)
.doc("Read a GPIO pin.")
.build(|cx| Ok(ScriptValue::I64(read_pin(cx.int_arg(0)))));
A ScriptFn carries the name, the namespace, a typed signature, the languages
that may see it, and the body. The body takes a call context: the arguments, and
a sink it emits script commands into when its effect belongs to the runtime
rather than to the return value. ScriptFn::value and ScriptFn::commands are
shorthands for the untyped shapes.
Reporting a failure¶
A body returns Err(message) to raise in the script that called it. Each host
raises the way its language does, and the message names the function:
| Language | What the script sees |
|---|---|
| Rhai | a runtime error, caught with try { .. } catch (e) { .. } |
| Lua | an error, caught with pcall |
| candela | a host_fn_error, caught with try { .. } catch "host_fn_error" { .. } |
An uncaught failure ends that one call and is reported like any other script error. The app keeps running, and commands the body queued before it failed are still applied.
Where the registration has to happen¶
Install a plugin that registers script functions through the plugin phase:
RunOptions::with_plugin, or App::add_plugin on the Rust SDK. That phase runs
before the script hosts load the app's program, which is the window in which a
registration can still be bound. candela resolves its host declarations while
the program compiles, and the artifact host binds them while the image loads, so
a function registered afterwards has nothing left to bind to. A late
registration warns and is ignored rather than half-working.
Registrations arrive in an app-wide ScriptFnRegistry resource. Each host
drains the entries its language may see just before it loads, then the registry
is sealed. Order is meaningful: a later function of the same namespace and name
shadows an earlier one, which is how a plugin replaces one of the runtime's own
builtins.
A plugin installed this way builds before the SDK builder's own
insert_resource and add_systems calls run, so a plugin that needs something
the builder inserts should read it from a system rather than from build.
Namespaces¶
ScriptNs |
Rhai | Lua | candela |
|---|---|---|---|
Builtin |
level(21) |
level(21) |
lumen::level(21) |
Extension |
level(21) |
level(21) |
native::level(21) |
Named("gpio") |
gpio::level(21) |
gpio.level(21) |
gpio::level(21) |
Builtin is the runtime's own surface; a plugin normally takes Extension or a
name of its own. Rhai gets a static module per named namespace, Lua a global
table, candela a host namespace.
candela needs a declaration behind every call, and the host writes it from the signature the plugin registered, so an app calls a plugin function without declaring anything. An app that spells the block itself keeps it: the host skips a namespace the source already declares, which is what a script written against an older release, and an artifact built from one, rely on.
lumen, window, document and history are the runtime's own namespaces and
the prelude declares them. A plugin that registers into one of them gets a
warning and no declaration, because a second block for a namespace displaces the
first and would cost the app every runtime function in it. Pick a name of your
own.
A name candela cannot spell in a declaration is refused at registration, with a message naming the plugin's namespace and function. That covers a keyword, a hyphen, a quote and the empty string. The app compiles and runs without the function rather than failing to compile at all.
A signature candela can name is declared with its types, whatever they are, and
the call's result is typed as declared. A call passing the wrong types or the
wrong number of arguments is refused when it runs, naming the parameter. A
variadic signature, an untyped parameter, or an optional trailing argument has
no such spelling, and is declared any name(...), which candela accepts at any
shape and leaves to the body.
A function whose result has no shape narrower than that, a parsed document for
instance, declares .ret(ScriptTy::Dynamic). Every host binds and declares it
the same way it binds ScriptTy::Any; what it adds is that the open return was
the intent, rather than a ret the author never called.
Shipping candela sugar¶
A plugin can ship candela source of its own, compiled ahead of the app's program, to offer method syntax over the functions it registered:
app.add_script_prelude(
"candela",
"gpio",
r#"
struct Pin { number: int }
fn pin(number: int) { return Pin { number: number }; }
impl Pin {
fn level(self) { return gpio::level(self.number); }
}
"#,
);
The script then calls pin(21).level(). An error inside that source is reported
against the plugin's namespace, not against a line of the app.
The sugar is compiled with the app, so it can call the runtime's own surface
(lumen::print(..)) as well as the plugin's. Two plugins may ship source for
the same namespace and both are kept, in registration order; a name written in
both is a compile error pointing into the wrapper.
Limits¶
Values cross as scalars, strings, arrays, and string-keyed maps. A handle to something in the world does not; pass an id and look it up.
Editor tooling reads the builtin metadata tables, which describe the runtime's own surface, so a plugin's functions do not appear in completion or hover.
A precompiled .cdlb gets the same declarations a live compile gets, folded
in from whatever was registered on the host before it compiled;
CandelaHost::compile_bytecode folds a registered function into its
namespace's block exactly as lumenc check and lumenc run do. lumenc build
does not register a plugin before it compiles your script's .cdlb, though, so
a script that will be compiled ahead of time still writes the
host "<ns>" { .. } block itself. The build does not object when it is missing;
the call compiles, the function holding it is left out of the image, and the
app starts without it. For the same reason a .cdl wrapper cannot reach an
artifact, and the artifact host says so when it is handed one. An artifact whose
block names a function no plugin registered fails its load, naming the
function.
Extending the asset server¶
The asset pipeline decides what a path becomes through a registry of loaders.
An AssetLoader claims file extensions, declares which kind of asset it
produces, and turns one load into a payload. The built-in image and SVG
paths are loaders like any other, registered by default and replaceable.
use lumen_assets::{AssetKind, AssetLoader, LoadContext, LoadErrorKind, LoadedAsset};
struct QoiLoader;
impl AssetLoader for QoiLoader {
fn extensions(&self) -> &[&str] {
&["qoi"]
}
fn kind(&self) -> AssetKind {
AssetKind::Image
}
fn load(&self, ctx: &LoadContext<'_>) -> Result<LoadedAsset, LoadErrorKind> {
let bytes = ctx.read_bytes()?;
let image = decode_qoi(&bytes)?;
Ok(LoadedAsset::Image(image))
}
}
struct QoiPlugin;
impl Plugin for QoiPlugin {
fn build(self, app: &mut App) {
lumen_assets::register_asset_loader(app, QoiLoader);
}
}
Four things about that signature are worth knowing before you write one.
Loading is synchronous, and a loader blocks a thread from the decode pool rather than awaiting. Do the expensive work there; that is the point of the pool.
A load is a path plus, optionally, bytes that were already resolved. Call
read_bytes when the decoder wants bytes and it does the right thing either
way; reach for path when the decoder wants to open the file itself, as the
image loader does. A path carrying pre-resolved bytes may be a
lumen://app/... URI rather than a filesystem location, so do not assume you
can open it.
The loader is chosen on the main thread when the load is queued, and travels with the job. Registering a loader while a decode is running is therefore safe and affects the next load, not the one in flight.
A later registration wins the extension, so registering png replaces the
built-in image path for PNGs. Paths whose extension nothing claims go to the
fallback loader, which is the image loader by default;
loaders_mut().set_fallback(None) makes them fail as unsupported instead.
Where the bytes come from is a separate seam. An AssetSource answers "do you
have this path?" with bytes or nothing, and the server asks each one before
queueing the load. The .lpak bundle source is installed by default, which is
why a packaged app resolves icons/sun.png out of its archive; register
another with AssetServer::register_source to serve assets from somewhere
else, such as bytes embedded in the binary. Sources run on the main thread
while the load is being queued, so keep them to an index lookup.
The same chain serves plugins and modules that load their own data. Take a
SourceReader from AssetServer::source_reader on the main thread and read
on any thread you like; it resolves bundles, then registered sources, then
the filesystem, so app data reaches you the way every other asset does
rather than through a direct filesystem read that a packaged app would miss.
The reader is a snapshot of the chain, so take a fresh one per request. The
audio module's track loading is the in-tree example.
Runtime modules¶
A runtime module is a plugin the app does not compile in: a shared library
built from the same Plugin trait as everything above, declared in the app's
lumen.toml (see the
[dependencies] reference), and
loaded at startup. The loader tells a runtime module from a
portable plugin by the entry symbol the file exports, so
the table entry looks the same for both. Once installed a module has the same
reach as any plugin: real systems in the tick stages, its own components and
resources, queries over the app's entities.
That reach comes from linking the engine as a Rust dynamic library rather than speaking a serialized ABI, and it sets the contract:
- A module is version-locked to the exact engine build. At load the engine reads the module's build id, inlined when the module compiled, and compares it against its own for exact equality. Nothing looser is safe: Rust's layout is not stable across rebuilds, and a skewed module would corrupt memory rather than fail. Rebuild modules for every engine release.
- Any load failure is a banner, not a dead app. A missing file, a
mismatched build id, a library that is not a module, or a panicking
constructor prints an unmissable stderr banner naming the module and the
reason, and the app boots without that module. The outcome is queryable
from the
LoadedModulesresource. - A prebuilt module is opened only by a dynamically linked engine. On
Linux and macOS that is every ordinary path: the installed
lumenc(solumenc runmarkup apps load modules), alumenc packagefolder (the launcher'sliblumenlinks the shared engine beside it), and a Rust SDK app builtprefer-dynamic. A process that compiled the engine into itself instead (a static--bundle, a plaincargo runbinary, a source-builtlumencwithout thedynamic-enginefeature) cannot open one: it would map a second engine instance that shares no state with the first. Portable plugins still load there. - A module the binary was built with loads anywhere. Compiled in, a
module has no file to open and nothing to verify: its constructor puts it
on a registry before
main, and the loader answers the declared name from there. That works on every platform, Windows included, and takes precedence over anything of the same name on disk. A name that this build neither compiles in nor can open gets a single stderr line rather than the failure banner, pointing atlumenc package --static, which is how an app gets that shape without a Rust toolchain. - A loaded module is never unloaded. The schedules hold function pointers into the library for as long as the app lives.
Authoring a module¶
A module crate builds both link shapes from one source, and depends on the SDK crate at the engine release it targets:
[lib]
crate-type = ["lib", "cdylib"]
[dependencies]
lumen-module = { git = "https://github.com/lumen-fx/lumen", tag = "v0.0.6" }
lumen-core = { git = "https://github.com/lumen-fx/lumen", tag = "v0.0.6" }
bevy_ecs = "0.19"
Implement Plugin as usual and export it with lumen_module!. The first
argument is the name apps declare the module under; the constructor
expression after it receives the config table the app declared:
use lumen_module::{lumen_module, App, ModuleConfig, Plugin};
struct ShapeTools {
units: String,
}
impl Plugin for ShapeTools {
fn build(self, app: &mut App) {
// Systems, resources, components - the full surface above.
}
}
lumen_module!("shape-tools", |config: ModuleConfig| ShapeTools {
units: config.str("units").unwrap_or("px").to_string(),
});
The macro generates the entries the engine calls, names them after the
module so two modules can live in one binary, and pulls in the engine-dylib
linkage, so the crate contains no unsafe code and cannot forget the link. An
app that declares the module under a different name will not find it. A
panic in the constructor or in build is caught inside the module and
reported to the loader as a failed install.
Build with the engine taken as a shared library. -C prefer-dynamic selects
it; passing an explicit --target keeps the flag off build scripts and proc
macros. Scope both in the module crate's .cargo/config.toml:
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "prefer-dynamic"]
[build]
target = "x86_64-unknown-linux-gnu"
Declare the result in the app:
[dependencies]
shape-tools = { path = "modules/shape-tools/target/x86_64-unknown-linux-gnu/release/shape_tools", config = { units = "mm" } }
A worked example: the audio module¶
std/audio in the tree is the shape above in production, and the engine
knows nothing about it. The one crate builds both link shapes (lib plus
cdylib), depends on lumen-module alone, and brings its whole surface
through the generic seams: the audio_* functions register through
add_script_fns, playback runs in the module's own systems and NonSend
state, the position lands in shared signals through the PropertyStore, and
end-of-track goes out as a plugin event the script's on_audio_end(path)
handler receives. A script-function body has no world access, so the
functions hand commands to the systems over a queue the module owns. It takes
an output device only for an interactive run, so a headless app gets the whole
surface and no hardware.
First-party modules under std/ build in the release's own cargo
invocation, which is what keeps their build ids equal to the engine they
ship beside. A crate added there is also in the link kit that release
publishes, so lumenc package --static can compile it into an app the day it
ships; there is no equivalent for a module from outside the toolchain, which
travels beside the executable instead.
A worked example: the canvas module¶
std/canvas is the same shape with pixels in it. It brings a markup element
rather than only functions, so it registers the canvas tag from
Plugin::build and the app declares the same tag under [dependencies] for
the compile. It adopts each element it answers for by watching for the tag,
and gives it a box by inserting an ImageComponent with a natural size, which
is the leaf shape the layout engine already sizes the way a canvas needs;
nothing loads, because the asset pipeline keys off a source component a canvas
never carries. It paints through the seam above, with the module's paint
feature supplying the vello its painter downcasts to.
The rest of its design is about where a script can run. A script-function body has no world, so a drawing call records into a journal the module owns and one system per tick replays the journal into a retained scene; the scene is what the extract hands the render world, and a tick that recorded nothing re-encodes nothing.
Portable plugins¶
A portable plugin is the other prebuilt kind: a cdylib declared in the same
[dependencies] table, loaded at
startup, that talks to the engine over a serialized C ABI instead of linking
it. That trade defines it:
- Narrower reach, by design. A portable plugin does not touch the ECS. It registers native functions the app's scripts call, ships language source that wraps them, and pushes events at the app from its own threads. A plugin wrapping a device, a service, or a native library fits here; a plugin that needs systems and components is a runtime module.
- No engine build lock. The plugin and the engine exchange bytes, so a built plugin works across engine builds as long as the handshake passes: the plugin ABI version plus the script wire version, each checked at load with an error naming both numbers. In practice a plugin is built once per release tag rather than per engine binary.
- Every desktop platform, every host shape. No engine dylib is needed, so a portable plugin loads on Windows and into statically linked builds - the shapes that refuse runtime modules.
- The same failure policy. A load failure banners and the app boots
without the plugin; the outcome lands in the same
LoadedModulesresource. - Loaded once, never unloaded. The library stays mapped for the life of the process. The app's scripts hold bodies that call into it and the plugin's own threads keep running its code, so there is no point at which unmapping it would be safe. Shutdown tells the plugin the app is going down; it closes nothing.
Authoring a portable plugin¶
A plugin crate is a cdylib depending on the SDK crate at a release tag:
[lib]
crate-type = ["cdylib"]
[dependencies]
lumen-plugin = { git = "https://github.com/lumen-fx/lumen", tag = "v0.0.6" }
Implement RuntimePlugin and export it with lumen_plugin!. Registration
describes functions with the same shapes the in-process
script-function surface uses:
use lumen_plugin::{
lumen_plugin, Error, InitCx, PluginFn, Registrar, RuntimePlugin, ScriptTy, ScriptValue,
};
struct Gpio;
impl RuntimePlugin for Gpio {
fn register(&self, r: &mut Registrar, cx: &InitCx) -> Result<(), Error> {
r.script_fn(
PluginFn::new("gpio_read")
.param("pin", ScriptTy::Int)
.ret(ScriptTy::Bool)
.doc("Read a GPIO pin.")
.build(|cx| {
let level = read_pin(cx.int_arg(0)).map_err(|e| e.to_string())?;
Ok(ScriptValue::Bool(level))
}),
);
r.prelude("candela", "gpio", "struct Gpio {}\n/* wrappers */");
Ok(())
}
}
lumen_plugin!(|| Gpio);
The macro generates the whole C-ABI surface; a plugin crate contains no
unsafe code, and a panic in a function body reaches the script as an error
rather than an abort. register runs once, before the app's scripts load,
and receives the app's directory, id, and the entry's config table through
InitCx. A function body gets the call's arguments and a command sink
(Cx::emit), so a call applies signal and tree writes on the tick it ran.
Events from a plugin's own threads¶
Registrar::host hands out a Host: a cheap, thread-safe handle a worker
thread keeps for the life of the process. Through it a plugin that watches a
file, polls a device, or waits on a socket delivers what it found without
being asked:
host.call_handler(event, key, fallback, args)calls a handler in the app's script. A per-keyon(event, key, fn)registration wins, else thefallbackfires; either way the handler receiveskeyas its first argument, thenargs. One handler serves many sources by branching on the key.host.emit(commands)applies script commands - signal writes, tree edits - with no handler involved.host.log(level, message)writes a line to the engine's diagnostic output, prefixed with the plugin's name.
Delivery is asynchronous: an event queues, wakes a parked app, and fires on
the next tick, on every active script host. Both calls return false once
the engine stops taking events, which is what a worker thread sees while the
app shuts down; the plugin's shutdown runs then, best effort.
One limit worth knowing: lumenc package currently applies the runtime
module gates to the whole [dependencies] table, so packaging an app whose
only entries are portable plugins still requires the shared engine and skips
Windows staging.
Compiler plugins¶
A compiler plugin is a Rust cdylib that lumenc loads while compiling an app.
It can rewrite the entry markup and CSS before parsing, transform the parsed
tree before the cascade, lint the cascaded tree, and emit extra build outputs.
An app declares its plugins in lumen.toml (see the
[[plugins]] reference); they run on
every compile path, lumenc check included.
Author one by depending on the SDK crate from the Lumen repo at a release tag and building a cdylib:
[package]
name = "markdown"
[lib]
crate-type = ["cdylib"]
[dependencies]
lumenc-plugin = { git = "https://github.com/lumen-fx/lumen", tag = "v0.0.6" }
Implement the CompilerPlugin trait and export it. Every hook has a default
no-op body; implement the ones the plugin needs:
use lumenc_plugin::{lumenc_plugin, CompilerPlugin, Ctx, Error, LayoutIR};
use serde::Deserialize;
#[derive(Deserialize, Default)]
#[serde(default)]
struct Cfg {
flavor: String,
}
#[derive(Default)]
struct Markdown;
impl CompilerPlugin for Markdown {
fn transform_markup(&self, src: &str, ctx: &Ctx) -> Result<Option<String>, Error> {
let cfg: Cfg = ctx.config()?;
Ok(Some(expand_markdown(src, &cfg.flavor)))
}
}
lumenc_plugin!(Markdown::default);
The full hook set, all defaulted to no-ops:
fn transform_markup(&self, src: &str, ctx: &Ctx) -> Result<Option<String>, Error>;
fn transform_css(&self, src: &str, ctx: &Ctx) -> Result<Option<String>, Error>;
fn transform_ir(&self, ir: &mut LayoutIR, ctx: &Ctx) -> Result<(), Error>;
fn lint(&self, ir: &LayoutIR, ctx: &Ctx) -> Result<Vec<Finding>, Error>;
fn emit(&self, ir: &LayoutIR, ctx: &Ctx) -> Result<Vec<Output>, Error>;
Ctx::config deserializes the entry's whole config table into any serde
type; a key the type does not declare is ignored unless the type opts into
deny_unknown_fields.
The macro generates the whole C-ABI surface; a plugin crate contains no unsafe
code. One instance serves the process and hooks take &self, so a plugin
holding mutable state brings its own lock.
The hooks, in pipeline order:
transform_markupandtransform_cssrewrite the entry file text before<include>and@importsplicing, so emitted directives resolve like hand-written ones. Only the entry markup and entry CSS pass through; included files and sibling page files do not. An app that ships nomain.cssruns the CSS hook over the empty string, so a plugin can synthesize the stylesheet.transform_irreceives the parsed tree after multi-page assembly and before asset resolution and the cascade, so an injected element gets its asset paths resolved and its styles applied like a hand-written one.lintreads the cascaded tree and returns findings, printed beside the built-in lint findings. They are advisory, never fail the build, and are not baked into the compiled artifact.emitreturns extra build products, written under.lumen/generated/<plugin>/in the app directory. Outputs are side products (manifests, reports, generated sources for a later compile), not inputs to the compile that produced them. Underlumenc checkthe hook still runs and its outputs are discarded.
The plugin and the compiler exchange serialized bytes over a versioned C ABI,
so a prebuilt lumenc loads plugins built by any Rust toolchain. At load,
lumenc verifies the plugin's ABI version and the IR format version it was
built against, and that the library reports the name the app declared; a
mismatch fails the compile with an error naming the plugin and the fix
(rebuild against the matching Lumen tag). Plugins must build with the
default panic = "unwind": the generated thunks catch a hook panic and
turn it into a compile error, which needs unwinding, so a plugin built with
panic = "abort" is refused at load with an error saying so.
The development loop pairs a prebuild hook with a path source, so the
plugin rebuilds before every compile that needs it:
[[hooks]]
when = "prebuild"
run = "cargo build --release --manifest-path plugins/markdown/Cargo.toml"
inputs = ["plugins/markdown/src/lib.rs"]
outputs = ["plugins/markdown/target/release/libmarkdown.so"]
[[plugins]]
name = "markdown"
path = "plugins/markdown/target/release/markdown"
lumenc check runs no hooks, so checking a clean tree before the first build
fails with "no file at" the declared path; run lumenc build once first.
Cargo names the built file with underscores (libmy_plugin.so for a package
named my-plugin); the probe tries both spellings, so an extensionless
path finds it either way.
The chain is loaded once per process: editing [[plugins]] or rebuilding a
path plugin during a lumenc run session takes effect on the next start,
not on a hot reload (a reload reruns the hooks of the already-loaded
libraries).
Known limits: plugin functions and transformed sources are invisible to
editor tooling (the LSP, lumenc lint --signals, and lumenc lint
--css-cascade all read the untransformed files), a plugin's lint findings
do not appear in lumenc lint --signals output, and a parse error in
rewritten markup carries positions into the rewritten text, flagged with
"in the markup as rewritten by compiler plugins".
Publishing¶
All three kinds ship the same way: through the registry at reg.lumenfx.dev,
where an app reaches them by name and version.
lpm publish claims the name once. Releasing a version of it is what a
release workflow does, and Lumen ships the workflow to call:
name: release
on:
push:
tags: ["v*"]
jobs:
release:
uses: lumen-fx/lumen/.github/workflows/[email protected]
with:
package: shape-tools
kind: module
lumen-version: 0.0.7
secrets:
lpm-token: ${{ secrets.LPM_TOKEN }}
It builds the package for every target it ships on, runs each build in a headless app to prove the loader opens it, publishes a GitHub release carrying the archives, and records the version in the registry.
kind selects the link shape and the platforms. module builds with
-C prefer-dynamic and an explicit --target, the flags a runtime module
needs, and ships on the four Unix targets. plugin and compiler-plugin
build a plain cdylib and ship on the five desktop targets. crate names the
cargo package when it differs from the registry name, manifest-path points
at a Cargo.toml that is not at the repository root, targets narrows the
list, and deps records the packages this one depends on, one name@req
per line.
An archive holds the library at its root, under the file name the by-name
probe looks for (libshape_tools.so, libshape_tools.dylib,
shape_tools.dll). That is what lpm unpacks and what the loader opens.
A release also records what it requires of the engine. A runtime module is
version-locked to the exact build it links, so it requires
lumen@=<version>; a portable plugin and a compiler plugin speak a C ABI
that survives a patch release, so they require lumen@>=<version>. An app
whose toolchain does not satisfy the requirement is told so while it
resolves, rather than by a banner at startup.