The markup, CSS, and Rhai stay the UI. An SDK lets your host language own the state and the event handlers instead — driving the same runtime, with same-tick reactivity, from whichever language your app already lives in.
lumen crateLumen runs on bevy_ecs, and the crate exposes it in bevy's own shape: add plugin groups, add real systems, run. Your handlers take Query, Res, MessageReader — nothing is stringly-typed.
cargo add lumen
Signals system param — signals.get_or::<i64>("count", 0) / set; the signals! macro mints typed handle structs so a name is spelled once.bevy_ecs systems scheduled into the live tick — read MessageReader<ClickEvent>, mutate TextContent through a query, chain with .chain().reset.run_if(on_click("reset")), plus on_toggle and on_change.LumenDefaultPlugins is the whole stack; .build().disable::<ScriptPlugin>() yields a pure-Rust app with no script host.use lumen::prelude::*; fn main() -> lumen::Result<()> { lumen::App::new() .add_plugins( LumenDefaultPlugins .with_markup(include_str!("counter.lmn")) .with_css(include_str!("counter.css")) .with_title("Lumen counter"), ) .insert_signal("count", 0i64) .add_systems(TickStage::Systems, (bump, label).chain()) .run() } fn bump(mut clicks: MessageReader<ClickEvent>, mut signals: Signals) { let hits = clicks.read().count() as i64; if hits > 0 { let n = signals.get_or::<i64>("count", 0) + hits; signals.set("count", n); } } fn label(signals: Signals, mut q: Query<(&LumenId, &mut TextContent)>) { let n = signals.get_or::<i64>("count", 0); for (id, mut text) in &mut q { if id.0 == "counter-label" { text.0 = format!("clicks: {n}"); } } }
lumen.hppOne header, C++17, no third-party dependencies. An RAII lumen::App that verifies the ABI on construction, typed lumen::Signal<T> handles safe from any thread, and std::function callbacks bridged into the runtime.
find_package(lumen-cpp CONFIG REQUIRED) target_link_libraries(myapp PRIVATE lumen::cpp)
Signal<T> handles for int64_t, double, bool, std::string, and Color — count += 1 writes, *count reads, safe from any thread.signal.watch(fn) — a real ABI subscription that fires when the value commits, not a polling loop.app.on_click(id, fn) taking void() or void(std::string); run_checked() throws lumen::Error on failure, run_headless(ticks) covers CI.App — the constructor throws lumen::Error on a header/library version mismatch.#include <lumen.hpp> lumen::Signal<int64_t> count{"count", 0}; lumen::Signal<std::string> label{"label", "0 clicks"}; int main() { lumen::App app("counter_app", {.title = "Counter", .width = 480, .height = 280}); app.on_click("increment", [] { count += 1; label = std::to_string(*count) + " clicks"; }); count.watch([](int64_t n) { // fires on the tick each new value commits }); app.run_checked(); }
lumen-uiPure ctypes — no compiled extension, no build step of its own. Subclass lumen.Model and every annotated field is a typed reactive signal: state.count += 1 is a runtime write.
pip install lumen-ui
lumen.Model — annotated fields become typed signals with IDE autocompletion; reading a field reads the signal, assigning writes it.Signal handles — .value reads and writes, .watch(fn) subscribes on commit, lumen.computed derives one signal from others.@app.on_click / on_double_click / on_long_press decorators; App is a context manager that frees the handle for you.app.run_headless(ticks) for CI, no display required; a LumenError hierarchy with one exception class per ABI status code.import lumen class Counter(lumen.Model): count: int = 0 label: str = "0 clicks" app = lumen.App("counter_app", title="Counter", size=(480, 280)) state = Counter(app) @app.on_click("increment") def _increment(_: str) -> None: state.count += 1 state.label = f"{state.count} clicks" with app: app.run() # blocks until the window closes
Under the three SDKs is one hand-written C ABI, lumen.h (version 0.4). It is the raw foundation — link the shared library, include one header, and drive Lumen from any language that speaks C. The same stringly layer also surfaces inside the SDKs as lumen.raw (Python) and lumen::raw (C++) for the rare call the typed surface doesn't cover.
cc counter.c -I lumen/ffi/include -llumen_ffi -o counter
lumen_signal_set_int64 / _get_int64, _float64, _bool, _color — plus lumen_signal_watch change subscriptions.lumen_app_on_click; headless ticks via lumen_app_run_headless.LUMEN_ERR_PANIC; it never unwinds into your frames.lumen_abi_version() against LUMEN_API_VERSION; the major+minor pair must match.#include <lumen.h> static void on_increment(const char *id, void *user) { int64_t n = 0; lumen_signal_get_int64(NULL, "count", &n); lumen_signal_set_int64(NULL, "count", n + 1); } int main(void) { if ((lumen_abi_version() >> 8) != (LUMEN_API_VERSION >> 8)) return 1; /* header vs library ABI mismatch */ lumen_signal_set_int64(NULL, "count", 0); LumenApp *app = lumen_app_new("path/to/app"); lumen_app_set_title(app, "Counter"); lumen_app_on_click(app, "increment", on_increment, NULL); return lumen_app_run(app); }
Markup, CSS, and Rhai still describe the view. An SDK just moves the signals and handlers into your host language — reads and writes land on the same PropertyStore, on the same tick, so a value set from Rust, C++, Python, or C reaches bound markup the very frame it changes.