Skip to main content

lumen/
lib.rs

1//! Lumen C-ABI surface.
2//!
3//! Opaque `LumenApp` plus a tagged `LumenValue` union let any language
4//! with C interop embed Lumen. The app's script reaches across the ABI
5//! through callbacks the embedder registers via `lumen_app_expose`.
6//! No Rust panic escapes any `lumen_*` fn - every entry point wraps its
7//! body in `catch_unwind` and stashes a UTF-8 message that
8//! C callers read through `lumen_last_error`.
9//!
10//! ## W6.12 hardening
11//!
12//! - `user_data` no longer uses the `usize` stash trick. It now lives
13//!   in [`UserData`], a `NonNull<c_void>` newtype with an explicit
14//!   `unsafe impl Send + Sync` whose SAFETY comment names the
15//!   embedder's contract.
16//! - [`LumenStatus`] split from 5 variants into a richer error
17//!   surface so C callers can branch on `ErrParse` vs `ErrCss` vs
18//!   `ErrWindow` instead of one opaque `ErrRuntime`.
19//! - [`LUMEN_ABI_VERSION`] + [`lumen_abi_version`] export a runtime
20//!   ABI version `(major << 16) | (minor << 8) | patch`.
21//! - [`lumen_last_error`] keeps its thread-local primary store but
22//!   now falls back to a global `Mutex<Option<CString>>` when the
23//!   thread has no error recorded. This trades a cheap lock for the
24//!   common case where embedders call `lumen_app_run` on thread A
25//!   and check the error on thread B.
26
27#![allow(clippy::missing_safety_doc)]
28
29// The linkage anchor for the `dynamic-engine` shape: naming the engine dylib
30// is what puts `liblumen_engine` in this library's link graph, and with
31// `-C prefer-dynamic` the engine crates below resolve into it instead of
32// compiling in. See the feature note in Cargo.toml.
33#[cfg(all(feature = "dynamic-engine", not(windows)))]
34use lumen_engine as _;
35
36use std::cell::RefCell;
37use std::ffi::{CStr, CString, c_char, c_int, c_void};
38use std::panic::AssertUnwindSafe;
39use std::path::PathBuf;
40use std::ptr::{self, NonNull};
41use std::sync::Mutex;
42
43use lumen_core::components::Color;
44use lumen_core::property_store::{
45    PropertyKey, PropertyValue, external_property_snapshot, push_external_property,
46};
47use lumen_core::signals::{push_external_array, push_external_clear};
48use lumen_runtime::{RunError, RunOptions};
49use lumen_script::{ScriptFn, ScriptValue};
50use std::collections::HashMap;
51use std::sync::Arc;
52
53// ============================================================
54// The Rust surface behind the shared library
55// ============================================================
56
57/// The Rust crates this library is built from, re-exported so a Rust program
58/// can reach them through it.
59///
60/// A Rust app that links the engine dynamically has to name every Lumen type
61/// through this one library. Depending on `lumen-core` (or any other piece)
62/// as well would compile a second copy of it into the app, and the two copies
63/// would be different types to the compiler and hold different globals - a
64/// signal set through one would not be visible through the other. Reaching
65/// everything through here keeps one copy, the one inside the shared library.
66///
67/// The Rust SDK (`lumenui`) is the intended reader; it re-exports what an app
68/// author actually writes against, so app code names neither this module nor
69/// the crates under it.
70pub mod sdk {
71    pub use bevy_ecs;
72    pub use lumen_core;
73    pub use lumen_runtime;
74    pub use lumen_script;
75    pub use lumen_widget;
76    pub use lumen_widget_macros;
77    #[cfg(feature = "embed-parser")]
78    pub use lumenc;
79}
80
81// The candela host every compiled app runs, compiled into a library with no
82// shared engine to open it beside: on Windows, where no engine dylib exists,
83// and in a static bundle. Its constructor puts it on the module registry, so
84// the loader answers an app's host the way it answers a compiled-in module.
85// The `dynamic-engine` shape opens it from the modules archive instead.
86#[cfg(not(feature = "dynamic-engine"))]
87use lumen_candela as _;
88
89// ============================================================
90// ABI version
91// ============================================================
92
93/// Major ABI version. Bump on breaking layout/signature changes.
94pub const LUMEN_ABI_MAJOR: u32 = 0;
95/// Minor ABI version. Bump on additive changes (new exports, new variants at the end of enums).
96///
97/// 0.4 added the change-subscription surface: [`LumenWatchFn`] +
98/// [`lumen_signal_watch`], a commit-fired signal-change callback wired
99/// through the running app's [`PropertyStore`] dirty machinery (no
100/// polling thread).
101///
102/// 0.5 added the graceful-shutdown hook: [`LumenCloseFn`] +
103/// [`lumen_app_on_close`], a close callback that fires on the OS close
104/// request (window button; Unix SIGINT/SIGTERM) *before* teardown and
105/// can veto the close by returning 0.
106///
107/// 0.6 added file-based-pages navigation: [`lumen_navigate`],
108/// [`lumen_navigate_back`], [`lumen_navigate_forward`], and
109/// [`lumen_current_page`], writing the reserved `route.request` cell through
110/// the shared `lumen_core::nav` bus (the same surface the script `page()`
111/// builtin and the Rust SDK use).
112///
113/// 0.7 added the link-not-embed launcher seam: [`lumen_app_new_from_lmna`],
114/// which builds a [`LumenApp`] from prebuilt LMNA artifact bytes with NO
115/// parser. The thin `lumenc` launcher compiles source to LMNA bytes in-process
116/// and hands them across this ABI to a dlopen'd liblumen, instead of
117/// static-linking the runtime. `lumen_app_new(dir)` is unchanged (still parses
118/// via the bundled parser). See `docs/design/link-not-embed.md`.
119///
120/// 0.8 added the dynamic DOM read side: [`LumenNode`], [`LumenNodeList`] +
121/// [`lumen_nodelist_free`] / [`lumen_nodelist_get`], and the query +
122/// traversal getters ([`lumen_query`], [`lumen_query_len`],
123/// [`lumen_query_single`], [`lumen_get_by_id`], [`lumen_document`],
124/// [`lumen_node_parent`] and siblings, [`lumen_node_children`],
125/// [`lumen_node_closest`], [`lumen_node_valid`]). All read the process-shared
126/// per-tick DOM snapshot; additive, so a minor bump.
127///
128/// 0.9 added the dynamic DOM write side + `window` / `document` / `history`:
129/// [`lumen_node_set_attr`] / [`lumen_node_remove_attr`] /
130/// [`lumen_node_set_text`], class-list edits
131/// ([`lumen_node_class_add`] / `_remove` / `_toggle`), inline style
132/// ([`lumen_node_set_style`] / [`lumen_node_remove_style`]), structure
133/// ([`lumen_node_spawn`], [`lumen_node_clone`], [`lumen_node_append`],
134/// [`lumen_node_insert_before`], [`lumen_node_set_parent`],
135/// [`lumen_node_replace_with`], [`lumen_node_remove`]), and the window /
136/// history / document entry points ([`lumen_window_set_href`],
137/// [`lumen_window_reload`], [`lumen_window_set_title`],
138/// [`lumen_window_set_size`], [`lumen_window_dpr`], [`lumen_history_go`],
139/// [`lumen_document_spawn`]). Mutations queue on the external DOM bus the
140/// runtime drains each tick; additive, so a minor bump.
141///
142/// 0.10 added the dynamic DOM event side (phase 4): register a C callback +
143/// user data against a node and event type with [`lumen_on`]
144/// (capture-phase opt-in), unbind with [`lumen_off`]. The callback receives a
145/// [`LumenEvent`] (scalar fields) plus accessor functions for the string
146/// fields ([`lumen_event_type`], [`lumen_event_key`], [`lumen_event_value`])
147/// and the propagation controls ([`lumen_event_prevent_default`],
148/// [`lumen_event_stop_propagation`], [`lumen_event_stop_immediate_propagation`]).
149/// The runtime invokes registered callbacks during capture -> target ->
150/// bubble propagation; additive, so a minor bump.
151///
152/// 0.11 added the low-level introspection read side (phase 5): post-layout
153/// geometry ([`lumen_node_rect`], [`lumen_node_content_rect`],
154/// [`lumen_node_scroll`], [`lumen_node_is_visible`], [`lumen_node_z_index`]),
155/// full computed style / attributes / inline style / component reads as
156/// key-value buffers ([`lumen_node_computed_style`], [`lumen_node_attrs`],
157/// [`lumen_node_inline_style`], [`lumen_node_component`]), class / component
158/// name lists ([`lumen_node_classes`], [`lumen_node_components`]), tree
159/// serialization ([`lumen_node_outer_markup`], [`lumen_dump_tree`]), entity
160/// id ([`lumen_node_entity_id`]), and global state ([`lumen_pointer_state`],
161/// [`lumen_frame_info`], [`lumen_signals_all`]), with the
162/// [`lumen_kvlist_free`] / [`lumen_strlist_free`] / [`lumen_string_free`]
163/// releasers. Additive, so a minor bump.
164///
165/// 0.12 added guarded markup injection (phase 6): read a node's children as
166/// `.lmn`-ish text with [`lumen_node_inner_markup`], and replace them from a
167/// markup fragment with [`lumen_node_set_inner_markup`]. The setter parses
168/// through the injected front-end (present on the from-source run path, a
169/// no-op on the precompiled-artifact path) and must not be fed untrusted
170/// content. Additive, so a minor bump.
171///
172/// 0.13 unified the scalar signal surface into one typed family:
173/// [`lumen_signal_set_str`] / [`lumen_signal_get_str`] join the existing
174/// int64 / float64 / bool / color pairs, the older stringifying setters
175/// and their string getter are removed, and the unread `LumenApp*` first
176/// parameter is gone from every typed accessor and from
177/// [`lumen_signal_array_len`] / [`lumen_signal_array_get_field`]. Kept
178/// names changed arity, so embedders rebuild against the new header
179/// rather than relinking.
180///
181/// 0.14 unified exposed-callback registration the same way: `LumenFn` is
182/// now the out-parameter callback, [`lumen_app_expose`] takes it, and the
183/// by-value callback type along with `lumen_app_expose_v2` are removed.
184/// Existing callbacks change signature, so embedders rebuild against the
185/// new header rather than relinking.
186pub const LUMEN_ABI_MINOR: u32 = 14;
187/// Patch ABI version. Bump on non-API metadata changes (docs, code, etc.).
188pub const LUMEN_ABI_PATCH: u32 = 0;
189
190/// Packed runtime ABI version `(major << 16) | (minor << 8) | patch`.
191/// Mirrored in `lumen.h` as `LUMEN_API_VERSION`. Embedders compare at
192/// runtime to refuse a header / shared-library mismatch.
193pub const LUMEN_ABI_VERSION: u32 =
194    (LUMEN_ABI_MAJOR << 16) | (LUMEN_ABI_MINOR << 8) | LUMEN_ABI_PATCH;
195
196/// Returns the packed ABI version this library was compiled with.
197/// Compare against `LUMEN_API_VERSION` from `lumen.h` at startup.
198#[unsafe(no_mangle)]
199pub extern "C" fn lumen_abi_version() -> u32 {
200    LUMEN_ABI_VERSION
201}
202
203// ============================================================
204// Value model
205// ============================================================
206
207/// Discriminant for [`LumenValue`].
208#[repr(u32)]
209#[derive(Debug, Copy, Clone, PartialEq, Eq)]
210pub enum LumenKind {
211    /// Unit / null.
212    Nil = 0,
213    /// `int` 0/1 in [`LumenValueData::boolean`].
214    Bool = 1,
215    /// Signed 64-bit integer in [`LumenValueData::integer`].
216    Int = 2,
217    /// IEEE-754 double in [`LumenValueData::float_`].
218    Float = 3,
219    /// UTF-8, NUL-terminated, in [`LumenValueData::string`].
220    String = 4,
221    /// Heterogeneous array, see [`LumenArrayView`].
222    Array = 5,
223    /// Key->value map, see [`LumenMapView`].
224    Map = 6,
225}
226
227/// Borrowed view of an array of [`LumenValue`]. Pointer must stay
228/// valid for the duration of the call returning it.
229#[repr(C)]
230#[derive(Copy, Clone)]
231pub struct LumenArrayView {
232    /// Pointer to the first item; null when `len == 0`.
233    pub items: *const LumenValue,
234    /// Item count.
235    pub len: usize,
236}
237
238/// Borrowed view of a map of [`LumenMapEntry`]. Pointer must stay
239/// valid for the duration of the call returning it.
240#[repr(C)]
241#[derive(Copy, Clone)]
242pub struct LumenMapView {
243    /// Pointer to the first entry; null when `len == 0`.
244    pub entries: *const LumenMapEntry,
245    /// Entry count.
246    pub len: usize,
247}
248
249/// Payload union for [`LumenValue`]. Read the field matching
250/// [`LumenValue::kind`].
251#[repr(C)]
252#[derive(Copy, Clone)]
253pub union LumenValueData {
254    /// Boolean payload (0 = false, non-zero = true).
255    pub boolean: c_int,
256    /// 64-bit signed integer payload.
257    pub integer: i64,
258    /// 64-bit float payload.
259    pub float_: f64,
260    /// UTF-8, NUL-terminated string payload.
261    pub string: *const c_char,
262    /// Array payload.
263    pub array: LumenArrayView,
264    /// Map payload.
265    pub map: LumenMapView,
266}
267
268/// One scalar / container value crossing the C ABI in either
269/// direction. Always pass `kind` consistently with the populated
270/// union field. Pointers are borrowed for the duration of the call;
271/// Lumen copies before returning to the script.
272#[repr(C)]
273#[derive(Copy, Clone)]
274pub struct LumenValue {
275    /// Discriminant - which union field is valid.
276    pub kind: LumenKind,
277    /// Payload union.
278    pub as_: LumenValueData,
279}
280
281/// One entry in a [`LumenMapView`]. `key` is UTF-8, NUL-terminated.
282#[repr(C)]
283#[derive(Copy, Clone)]
284pub struct LumenMapEntry {
285    /// UTF-8, NUL-terminated key.
286    pub key: *const c_char,
287    /// Value.
288    pub value: LumenValue,
289}
290
291/// Return code for every `lumen_*` C function.
292///
293/// W6.12 split the legacy 5-variant enum into a richer surface so C
294/// callers can distinguish "parse failure" from "asset failure" from
295/// "window backend failure" without inspecting the [`lumen_last_error`]
296/// string. Numeric values are stable; new variants append.
297#[repr(u32)]
298#[derive(Debug, Copy, Clone, PartialEq, Eq)]
299pub enum LumenStatus {
300    /// Operation succeeded.
301    Ok = 0,
302    /// A path argument was missing or could not be resolved.
303    ErrBadPath = 1,
304    /// A non-path argument was missing or malformed.
305    ErrBadArg = 2,
306    /// Generic runtime error (legacy catch-all; new code should pick a
307    /// more specific variant when possible).
308    ErrRuntime = 3,
309    /// Internal error (Rust panic caught at the boundary).
310    ErrInternal = 4,
311    /// HTML / template parse failure.
312    ErrParse = 5,
313    /// CSS parse / cascade failure.
314    ErrCss = 6,
315    /// Asset load / decode failure.
316    ErrAsset = 7,
317    /// Window backend (winit / wgpu surface) failure.
318    ErrWindow = 8,
319    /// Script compile / runtime failure.
320    ErrScript = 9,
321    /// Generic I/O error (filesystem, network).
322    ErrIo = 10,
323    /// `lumen_*` was called with a handle that does not belong to a
324    /// live `LumenApp` (use-after-free / null after move).
325    ErrInvalidHandle = 11,
326    /// A passed value was syntactically valid but semantically wrong
327    /// (e.g. a `kind`/payload mismatch on `LumenValue`).
328    ErrInvalidValue = 12,
329    /// A Rust panic occurred at the boundary. This is the code every
330    /// export returns for a caught panic; the legacy `ErrInternal` (4) is
331    /// a separate value and is no longer returned.
332    ErrPanic = 13,
333    /// A caller-provided output buffer was too small. The associated
334    /// `out_len` out-parameter (where the export takes one) is set to the
335    /// number of bytes required, including the trailing NUL. Introduced in
336    /// ABI 0.3 for the string / array read-back accessors.
337    ErrBufferTooSmall = 14,
338}
339
340/// Signature of an exposed callback. Register with [`lumen_app_expose`].
341///
342/// The result travels through the `out` pointer rather than a by-value
343/// return. `LumenValue` is larger than the 16-byte register-pair
344/// threshold, so returning it by value forces every non-Rust binding to
345/// hand-encode the platform's aggregate-return (SysV `sret`) convention;
346/// an out-parameter is expressible in `ctypes`, `libffi`, and every other
347/// binding layer without target-specific code.
348///
349/// `out` is never null when Lumen invokes the callback, and points to a
350/// single writable [`LumenValue`] that Lumen has zero-initialised to
351/// `LumenKind::Nil`, so a callback that wants to return nil may leave it
352/// untouched.
353///
354/// `argv` is borrowed for the duration of the call. Lumen reads `*out`
355/// into a `Dynamic` once the callback returns, so any pointers the
356/// written value carries must outlive the call rather than point into
357/// the callback's own frame; static or heap storage the callback owns
358/// works, a local buffer does not.
359pub type LumenFn = unsafe extern "C" fn(
360    out: *mut LumenValue,
361    argc: c_int,
362    argv: *const LumenValue,
363    user_data: *mut c_void,
364);
365
366/// Id-scoped native click callback (ABI 0.3). Registered with
367/// [`lumen_app_on_click`]; invoked once per [`ClickEvent`] whose target
368/// element carries the matching `LumenId`. `id` is the element id
369/// (UTF-8, NUL-terminated), borrowed for the duration of the call.
370///
371/// Fires on the Lumen tick thread (which may be a `bevy_ecs` worker,
372/// not the thread that built the app); `user_data` carries the same
373/// Send/Sync contract as [`lumen_app_expose`]'s.
374pub type LumenClickFn = unsafe extern "C" fn(id: *const c_char, user_data: *mut c_void);
375
376/// App-level close callback (ABI 0.5). Registered with
377/// [`lumen_app_on_close`]; invoked once per OS close request - the
378/// window close button, or (Unix) the first SIGINT/SIGTERM - *before*
379/// the runtime tears anything down, so embedders get a last chance to
380/// persist state.
381///
382/// Return nonzero to allow the close (the loop exits and `lumen_app_run`
383/// returns), or 0 to veto it and keep the window open - mirroring the
384/// script-side `on_close()` returning `false`. On Unix a second
385/// SIGINT/SIGTERM bypasses the hook and exits immediately, so a vetoing
386/// embedder cannot wedge shutdown.
387///
388/// Fires on the Lumen tick thread; `user_data` carries the same
389/// Send/Sync contract as [`lumen_app_expose`]'s.
390pub type LumenCloseFn = unsafe extern "C" fn(user_data: *mut c_void) -> c_int;
391
392/// Signal-change subscription callback (ABI 0.4). Registered with
393/// [`lumen_signal_watch`]; fires once per tick in which the watched
394/// global signal's committed value changed (plus once on the first tick
395/// the value is observed after the watch is registered).
396///
397/// `name` is the watched signal name (UTF-8, NUL-terminated). `value`
398/// points to the new committed value, borrowed for the duration of the
399/// call - Lumen frees it afterwards, so copy anything you keep. The
400/// [`LumenValue`] mirrors the stored [`PropertyValue`]:
401/// `Bool`->`LUMEN_BOOL`, `I64`->`LUMEN_INT`, `F64`->`LUMEN_FLOAT`,
402/// `Str`->`LUMEN_STRING`, and `Color`->`LUMEN_INT` packed big-endian
403/// `0xRRGGBBAA` (unpack channels with `(v>>24)&0xff` ... `v&0xff`). Other
404/// variants (`Vec2`, `Custom`) arrive as `LUMEN_NIL`.
405///
406/// Fires on the Lumen tick thread (same Send/Sync `user_data` contract as
407/// [`lumen_app_expose`]). Delivery is per-tick coalesced: several mid-tick
408/// writes collapse into a single callback carrying the final value, mirroring
409/// [`PropertyStore`]'s own GObject-style notify semantics.
410pub type LumenWatchFn =
411    unsafe extern "C" fn(name: *const c_char, value: *const LumenValue, user_data: *mut c_void);
412
413// ============================================================
414// Opaque app handle
415// ============================================================
416
417/// Builder + handle for one embedded Lumen application. Construct
418/// with `lumen_app_new`, populate with `lumen_app_expose` /
419/// `lumen_app_set_*`, run with `lumen_app_run` (which consumes the
420/// handle). If you decide not to run, `lumen_app_free` drops it.
421pub struct LumenApp {
422    dir: PathBuf,
423    /// Prebuilt LMNA artifact bytes (link-not-embed launcher path). When
424    /// `Some`, the app runs from these bytes with NO parser and `dir` is used
425    /// only as the base directory for relative asset resolution. `None` is the
426    /// classic from-source path where `dir` names an app directory the bundled
427    /// parser reads. Set by [`lumen_app_new_from_lmna`]; `None` for
428    /// [`lumen_app_new`].
429    artifact_bytes: Option<Vec<u8>>,
430    title: Option<String>,
431    size: Option<(u32, u32)>,
432    exposed: Vec<ExposedFn>,
433    /// Id-scoped native click handlers registered via
434    /// [`lumen_app_on_click`]. Keyed on element id; a second
435    /// registration for the same id replaces the first.
436    click_handlers: HashMap<String, (LumenClickFn, UserData)>,
437    /// App-level close hook registered via [`lumen_app_on_close`]. A
438    /// second registration replaces the first.
439    close_handler: Option<(LumenCloseFn, UserData)>,
440    /// The app's `[[plugins]]` compiler-plugin chain, resolved eagerly in
441    /// [`lumen_app_new`] beside the directory validation, so a bad
442    /// declaration fails at construction time. `None` on the artifact path
443    /// and in a no-parser build, where nothing compiles from source.
444    compiler_plugins: Option<std::sync::Arc<dyn lumen_runtime::CompilerPlugins>>,
445}
446
447/// Embedder-supplied opaque pointer carried across the FFI to native
448/// callbacks. A script host moves the wrapping closure across
449/// threads, which requires `Send + Sync` - that bound is impossible
450/// to satisfy generically for `*mut c_void`, so this newtype carries
451/// an explicit unsafe impl with the contract spelled out in SAFETY.
452///
453/// `None` represents the documented "no user data" case (the embedder
454/// passes `nullptr`); we never dereference it. `NonNull` makes the
455/// non-null invariant a type-system rule rather than a runtime check.
456#[derive(Copy, Clone)]
457pub struct UserData(Option<NonNull<c_void>>);
458
459impl UserData {
460    /// Wrap a raw embedder pointer. `None` if `p` is null.
461    pub fn from_raw(p: *mut c_void) -> Self {
462        Self(NonNull::new(p))
463    }
464
465    /// Recover the raw pointer for the dispatcher call. Caller must
466    /// uphold all of `NonNull::as_ptr`'s usual contracts.
467    pub fn as_ptr(self) -> *mut c_void {
468        match self.0 {
469            Some(p) => p.as_ptr(),
470            None => ptr::null_mut(),
471        }
472    }
473}
474
475// SAFETY:
476//
477// `UserData` wraps an `Option<NonNull<c_void>>`. The pointer itself
478// is opaque to lumen and is only ever passed back to the embedder's
479// own `LumenFn` callback. The embedder is contractually responsible
480// for ensuring that whatever object lives behind the pointer is safe
481// to read from the Lumen script thread (where the callback fires)
482// AND from any thread the script may move the closure to.
483//
484// This is the same contract Qt's `QObject*` user-data fields and
485// GLib's `gpointer user_data` carry. Documented in `lumen.h` and the
486// LumenApp expose docstring.
487//
488// Concretely, `Send + Sync` here means:
489//   - **Send**: it is sound to move a `UserData` between threads.
490//     Moving the pointer doesn't dereference it; the embedder must
491//     also ensure that the *referent* tolerates being read from
492//     whichever thread the callback eventually runs on.
493//   - **Sync**: shared references can cross thread boundaries. Same
494//     rationale: lumen never dereferences the pointer.
495unsafe impl Send for UserData {}
496unsafe impl Sync for UserData {}
497
498struct ExposedFn {
499    name: String,
500    fn_ptr: LumenFn,
501    /// Embedder-supplied opaque pointer. See [`UserData`] for the
502    /// Send/Sync rationale and embedder contract.
503    user_data: UserData,
504    arg_count: usize,
505}
506
507thread_local! {
508    static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
509}
510
511/// Global last-error fallback. Populated alongside the thread-local
512/// store so callers that probe `lumen_last_error` on a thread that
513/// did not raise the error still see *a* useful message. Documented
514/// caveat: multi-threaded embedders can race on this slot; whoever
515/// wrote last wins.
516static GLOBAL_LAST_ERROR: Mutex<Option<CString>> = Mutex::new(None);
517
518fn set_last_error(s: impl Into<String>) {
519    let s = s.into();
520    if let Ok(c) = CString::new(s.clone()) {
521        LAST_ERROR.with(|cell| {
522            *cell.borrow_mut() = Some(c.clone());
523        });
524        // Best-effort: a poisoned global mutex is recovered via
525        // `into_inner_or` semantics (clone the inner Option out and
526        // overwrite). The mutex contents are just a `CString`; a
527        // panic in a previous critical section doesn't invalidate it.
528        match GLOBAL_LAST_ERROR.lock() {
529            Ok(mut g) => *g = Some(c),
530            Err(poisoned) => {
531                let mut g = poisoned.into_inner();
532                *g = Some(c);
533            }
534        }
535    }
536}
537
538fn catch<F>(f: F) -> LumenStatus
539where
540    F: FnOnce() -> LumenStatus,
541{
542    match std::panic::catch_unwind(AssertUnwindSafe(f)) {
543        Ok(s) => s,
544        Err(_) => {
545            set_last_error("rust panic across FFI");
546            LumenStatus::ErrPanic
547        }
548    }
549}
550
551/// Value-returning panic guard for C entry points that hand back a plain
552/// scalar (a handle, a token) rather than a [`LumenStatus`]. On a caught
553/// panic it records the error and returns `fallback`, so no unwind crosses
554/// the ABI.
555fn catch_val<T, F>(fallback: T, f: F) -> T
556where
557    F: FnOnce() -> T,
558{
559    match std::panic::catch_unwind(AssertUnwindSafe(f)) {
560        Ok(v) => v,
561        Err(_) => {
562            set_last_error("rust panic across FFI");
563            fallback
564        }
565    }
566}
567
568// ============================================================
569// C entry points
570// ============================================================
571
572/// Allocate a new app rooted at `dir` (UTF-8, NUL-terminated). The
573/// directory must exist and contain `lumen.toml` and/or `src/main.lmn`.
574/// Returns null on error; call `lumen_last_error` for details.
575///
576/// ABI 0.3 made this validation eager: prior versions accepted any
577/// path and only surfaced a bad directory later, inside
578/// `lumen_app_run` (i.e. only after opening a window). The directory is
579/// now `stat`-ed up front and its contents checked, so a bad app
580/// directory fails at construction time with a null return.
581#[unsafe(no_mangle)]
582pub unsafe extern "C" fn lumen_app_new(dir: *const c_char) -> *mut LumenApp {
583    let r: Result<*mut LumenApp, String> = std::panic::catch_unwind(AssertUnwindSafe(|| {
584        if dir.is_null() {
585            return Err("null dir".to_string());
586        }
587        let s = match unsafe { CStr::from_ptr(dir) }.to_str() {
588            Ok(s) => s,
589            Err(_) => return Err("dir not utf-8".to_string()),
590        };
591        let path = PathBuf::from(s);
592        // Eager directory validation (ABI 0.3). The doc promised this;
593        // the implementation now delivers it.
594        let meta = std::fs::metadata(&path)
595            .map_err(|e| format!("lumen_app_new: cannot access app directory {s:?}: {e}"))?;
596        if !meta.is_dir() {
597            return Err(format!("lumen_app_new: {s:?} is not a directory"));
598        }
599        // An app is `lumen.toml` at its root with its code under `src/`.
600        // Either one present is enough to treat this as an app directory; a
601        // flat app, or a missing entry file, gets its own message from the
602        // layout resolution (`lumen_runtime::app_layout`) at load, which says
603        // more than this check can.
604        if !path.join("lumen.toml").is_file() && !path.join("src").join("main.lmn").is_file() {
605            return Err(format!(
606                "lumen_app_new: app directory {s:?} contains neither lumen.toml nor src/main.lmn"
607            ));
608        }
609        // The app's registry packages and the `[[plugins]]` chain over them
610        // resolve as eagerly as the directory above and fail the same way: a
611        // bad declaration is a bad app directory, and surfacing it here beats
612        // surfacing it after a window opened.
613        #[cfg(feature = "embed-parser")]
614        let compiler_plugins = {
615            let resolved =
616                lumenc::registry_packages(&path, lumen_runtime::modules::Target::Desktop)
617                    .map_err(|e| e.to_string())?;
618            Some(
619                lumenc::plugin_host::compiler_plugins_for(&path, false, &resolved.compiler_plugins)
620                    .map_err(|e| e.to_string())?,
621            )
622        };
623        #[cfg(not(feature = "embed-parser"))]
624        let compiler_plugins = None;
625        Ok(Box::into_raw(Box::new(LumenApp {
626            dir: path,
627            artifact_bytes: None,
628            title: None,
629            size: None,
630            exposed: Vec::new(),
631            click_handlers: HashMap::new(),
632            close_handler: None,
633            compiler_plugins,
634        })))
635    }))
636    .unwrap_or_else(|_| Err("panic in lumen_app_new".to_string()));
637    match r {
638        Ok(p) => p,
639        Err(msg) => {
640            set_last_error(msg);
641            ptr::null_mut()
642        }
643    }
644}
645
646/// Allocate a new app from prebuilt LMNA artifact bytes (ABI 0.7). `data`
647/// points to `len` bytes of a `lumenc`-compiled artifact (magic `LMNA`);
648/// Lumen copies them in immediately, so the caller may free `data` as soon as
649/// this returns. `base_dir` (UTF-8, NUL-terminated, or null) is the directory
650/// relative asset paths in the artifact resolve against; null means the
651/// current directory.
652///
653/// This is the link-not-embed launcher seam: the thin `lumenc` launcher
654/// compiles source to LMNA bytes in-process (it has the parser) and hands them
655/// here, so the runtime runs with NO parser and never touches a source file.
656/// Contrast [`lumen_app_new`], which takes a source directory and parses via
657/// the bundled parser.
658///
659/// Returns null on error (null/empty `data`, or `base_dir` not UTF-8); call
660/// [`lumen_last_error`] for details. The artifact bytes themselves are
661/// validated lazily at run time (`lumen_app_run` / `lumen_app_run_headless`),
662/// where a bad magic / version surfaces as an error status.
663#[unsafe(no_mangle)]
664pub unsafe extern "C" fn lumen_app_new_from_lmna(
665    data: *const u8,
666    len: usize,
667    base_dir: *const c_char,
668) -> *mut LumenApp {
669    let r: Result<*mut LumenApp, String> = std::panic::catch_unwind(AssertUnwindSafe(|| {
670        if data.is_null() || len == 0 {
671            return Err("lumen_app_new_from_lmna: null or empty LMNA data".to_string());
672        }
673        // Copy the caller's bytes in immediately (the pointer is borrowed only
674        // for this call).
675        let bytes = unsafe { std::slice::from_raw_parts(data, len) }.to_vec();
676        // Resolve the base dir for relative asset paths. Null -> ".".
677        let dir = if base_dir.is_null() {
678            PathBuf::from(".")
679        } else {
680            match unsafe { CStr::from_ptr(base_dir) }.to_str() {
681                Ok(s) => PathBuf::from(s),
682                Err(_) => return Err("lumen_app_new_from_lmna: base_dir not utf-8".to_string()),
683            }
684        };
685        Ok(Box::into_raw(Box::new(LumenApp {
686            dir,
687            artifact_bytes: Some(bytes),
688            title: None,
689            size: None,
690            exposed: Vec::new(),
691            click_handlers: HashMap::new(),
692            close_handler: None,
693            compiler_plugins: None,
694        })))
695    }))
696    .unwrap_or_else(|_| Err("panic in lumen_app_new_from_lmna".to_string()));
697    match r {
698        Ok(p) => p,
699        Err(msg) => {
700            set_last_error(msg);
701            ptr::null_mut()
702        }
703    }
704}
705
706/// Expose a native callback to the app's script under `name`. `arg_count` is
707/// the arity (0..=8 sensible); a host that dispatches on arity reads it, and
708/// candela binds the call variadically. Pointers are stored by value; the embedder owns
709/// `user_data` and must keep it valid until `lumen_app_run` returns.
710///
711/// `func` is a [`LumenFn`]: it writes its result through the `out` pointer
712/// it receives as its first argument. See that type for the full contract.
713///
714/// Every script host the app runs gets the registration. A candela script
715/// declares what it calls, so it reaches an exposed `now_ms` as
716/// `native::now_ms()` after declaring `host "native" { any now_ms(...); }`;
717/// a host with global functions calls `now_ms()` directly.
718#[unsafe(no_mangle)]
719pub unsafe extern "C" fn lumen_app_expose(
720    app: *mut LumenApp,
721    name: *const c_char,
722    arg_count: u32,
723    func: Option<LumenFn>,
724    user_data: *mut c_void,
725) -> LumenStatus {
726    catch(|| {
727        let Some(func) = func else {
728            set_last_error("null fn");
729            return LumenStatus::ErrBadArg;
730        };
731        if app.is_null() {
732            set_last_error("null app");
733            return LumenStatus::ErrInvalidHandle;
734        }
735        if name.is_null() {
736            set_last_error("null name");
737            return LumenStatus::ErrBadArg;
738        }
739        let app = unsafe { &mut *app };
740        let name_str = match unsafe { CStr::from_ptr(name) }.to_str() {
741            Ok(s) => s.to_owned(),
742            Err(_) => {
743                set_last_error("name not utf-8");
744                return LumenStatus::ErrBadArg;
745            }
746        };
747        app.exposed.push(ExposedFn {
748            name: name_str,
749            fn_ptr: func,
750            user_data: UserData::from_raw(user_data),
751            arg_count: arg_count as usize,
752        });
753        LumenStatus::Ok
754    })
755}
756
757/// Register an id-scoped native click handler (ABI 0.3).
758///
759/// `cb` fires once per click on the element whose `LumenId` equals `id`,
760/// routed by the runtime - no `main.lmn` forwarding boilerplate and no
761/// per-embedder dispatch table over the global `on_click(id)` hook. A
762/// second registration for the same `id` **replaces** the first. Must be
763/// called before `lumen_app_run` / `lumen_app_run_headless`.
764///
765/// The handler fires on the Lumen tick thread; `user_data` carries the
766/// same Send/Sync contract as [`lumen_app_expose`]'s. The native routing
767/// coexists with any script-side `on_click(id)` handler - both observe
768/// the same click.
769#[unsafe(no_mangle)]
770pub unsafe extern "C" fn lumen_app_on_click(
771    app: *mut LumenApp,
772    id: *const c_char,
773    cb: Option<LumenClickFn>,
774    user_data: *mut c_void,
775) -> LumenStatus {
776    catch(|| {
777        let Some(cb) = cb else {
778            set_last_error("lumen_app_on_click: null callback");
779            return LumenStatus::ErrBadArg;
780        };
781        if app.is_null() {
782            set_last_error("lumen_app_on_click: null app");
783            return LumenStatus::ErrInvalidHandle;
784        }
785        if id.is_null() {
786            set_last_error("lumen_app_on_click: null id");
787            return LumenStatus::ErrBadArg;
788        }
789        let app = unsafe { &mut *app };
790        let id_str = match unsafe { CStr::from_ptr(id) }.to_str() {
791            Ok(s) => s.to_owned(),
792            Err(_) => {
793                set_last_error("lumen_app_on_click: id not utf-8");
794                return LumenStatus::ErrBadArg;
795            }
796        };
797        app.click_handlers
798            .insert(id_str, (cb, UserData::from_raw(user_data)));
799        LumenStatus::Ok
800    })
801}
802
803/// Register an app-level close hook (ABI 0.5).
804///
805/// `cb` fires once per OS close request - the window close button, or
806/// (Unix) the first SIGINT/SIGTERM - on the Lumen tick thread, *before*
807/// the runtime tears down the window, GPU state, or script host. Return
808/// nonzero to allow the close; return 0 to veto it and keep the window
809/// open (the hook fires again on the next close request). A second
810/// registration **replaces** the first. Must be called before
811/// `lumen_app_run`. The hook never fires under `lumen_app_run_headless`
812/// (no window, no OS close request).
813///
814/// The native hook coexists with any script-side `on_close()` - both
815/// observe the same close request, and either may veto it.
816#[unsafe(no_mangle)]
817pub unsafe extern "C" fn lumen_app_on_close(
818    app: *mut LumenApp,
819    cb: Option<LumenCloseFn>,
820    user_data: *mut c_void,
821) -> LumenStatus {
822    catch(|| {
823        let Some(cb) = cb else {
824            set_last_error("lumen_app_on_close: null callback");
825            return LumenStatus::ErrBadArg;
826        };
827        if app.is_null() {
828            set_last_error("lumen_app_on_close: null app");
829            return LumenStatus::ErrInvalidHandle;
830        }
831        let app = unsafe { &mut *app };
832        app.close_handler = Some((cb, UserData::from_raw(user_data)));
833        LumenStatus::Ok
834    })
835}
836
837/// One registered signal watcher: the native callback plus its opaque
838/// embedder pointer.
839type WatcherEntry = (LumenWatchFn, UserData);
840
841/// Process-wide signal-change subscription registry (ABI 0.4). Keyed on
842/// the global signal name; each name may carry several watchers. Read
843/// every tick by the dispatch system installed in [`build_run_options`],
844/// mutated by [`lumen_signal_watch`] from any thread.
845static SIGNAL_WATCHERS: OnceLock<Mutex<HashMap<String, Vec<WatcherEntry>>>> = OnceLock::new();
846
847fn signal_watchers() -> &'static Mutex<HashMap<String, Vec<WatcherEntry>>> {
848    SIGNAL_WATCHERS.get_or_init(|| Mutex::new(HashMap::new()))
849}
850
851/// Subscribe to changes of the global signal `name` (ABI 0.4).
852///
853/// `cb` fires on the Lumen tick thread once per tick in which `name`'s
854/// committed [`PropertyStore`] value changed - and once on the first tick
855/// the value is observed after registration, so a freshly-registered
856/// watcher immediately learns the current state. This is a real
857/// commit-fired subscription wired through the running app's
858/// [`PropertyStore`] dirty machinery, not a background polling loop; it
859/// only fires while the app is running (`lumen_app_run` /
860/// `lumen_app_run_headless`).
861///
862/// Registration is global and independent of any `LumenApp` handle, so it
863/// may be called before or after the app is built, from any thread. A
864/// second `lumen_signal_watch` for the same `name` adds another watcher
865/// (they do not replace one another). `user_data` carries the same
866/// Send/Sync contract as [`lumen_app_expose`]'s.
867///
868/// Returns [`LumenStatus::ErrBadArg`] when `cb` is null or `name` is
869/// null / non-UTF-8.
870#[unsafe(no_mangle)]
871pub unsafe extern "C" fn lumen_signal_watch(
872    name: *const c_char,
873    cb: Option<LumenWatchFn>,
874    user_data: *mut c_void,
875) -> LumenStatus {
876    catch(|| {
877        let Some(cb) = cb else {
878            set_last_error("lumen_signal_watch: null callback");
879            return LumenStatus::ErrBadArg;
880        };
881        let Some(n) = typed_signal_name(name) else {
882            set_last_error("lumen_signal_watch: null or non-utf8 name");
883            return LumenStatus::ErrBadArg;
884        };
885        signal_watchers()
886            .lock()
887            .unwrap_or_else(|e| e.into_inner())
888            .entry(n)
889            .or_default()
890            .push((cb, UserData::from_raw(user_data)));
891        LumenStatus::Ok
892    })
893}
894
895/// Materialise a stored [`PropertyValue`] into a borrowed [`LumenValue`]
896/// for one watch callback. A `Str` payload's backing `CString` is stashed
897/// in `keep` so its pointer stays live for the duration of the call;
898/// `Color` is packed into a `LUMEN_INT` as big-endian `0xRRGGBBAA`.
899fn property_to_lumen(v: &PropertyValue, keep: &mut Option<CString>) -> LumenValue {
900    match v {
901        PropertyValue::Bool(b) => LumenValue {
902            kind: LumenKind::Bool,
903            as_: LumenValueData {
904                boolean: *b as c_int,
905            },
906        },
907        PropertyValue::I64(n) => LumenValue {
908            kind: LumenKind::Int,
909            as_: LumenValueData { integer: *n },
910        },
911        PropertyValue::F64(n) => LumenValue {
912            kind: LumenKind::Float,
913            as_: LumenValueData { float_: *n },
914        },
915        PropertyValue::Str(s) => {
916            let cs = CString::new(s.as_ref()).unwrap_or_default();
917            let ptr = cs.as_ptr();
918            *keep = Some(cs);
919            LumenValue {
920                kind: LumenKind::String,
921                as_: LumenValueData { string: ptr },
922            }
923        }
924        PropertyValue::Color(c) => {
925            let [r, g, b, a] = c.to_rgba8();
926            let packed = ((r as i64) << 24) | ((g as i64) << 16) | ((b as i64) << 8) | (a as i64);
927            LumenValue {
928                kind: LumenKind::Int,
929                as_: LumenValueData { integer: packed },
930            }
931        }
932        PropertyValue::Vec2(_) | PropertyValue::Custom(_) => LumenValue {
933            kind: LumenKind::Nil,
934            as_: LumenValueData { integer: 0 },
935        },
936    }
937}
938
939/// Override the window title (default: derived from `lumen.toml` or
940/// the directory name).
941#[unsafe(no_mangle)]
942pub unsafe extern "C" fn lumen_app_set_title(
943    app: *mut LumenApp,
944    title: *const c_char,
945) -> LumenStatus {
946    catch(|| {
947        if app.is_null() {
948            return LumenStatus::ErrInvalidHandle;
949        }
950        if title.is_null() {
951            return LumenStatus::ErrBadArg;
952        }
953        let s = match unsafe { CStr::from_ptr(title) }.to_str() {
954            Ok(s) => s,
955            Err(_) => return LumenStatus::ErrBadArg,
956        };
957        unsafe { &mut *app }.title = Some(s.to_owned());
958        LumenStatus::Ok
959    })
960}
961
962/// Override the initial window size in logical pixels.
963#[unsafe(no_mangle)]
964pub unsafe extern "C" fn lumen_app_set_size(app: *mut LumenApp, w: u32, h: u32) -> LumenStatus {
965    catch(|| {
966        if app.is_null() {
967            return LumenStatus::ErrInvalidHandle;
968        }
969        unsafe { &mut *app }.size = Some((w, h));
970        LumenStatus::Ok
971    })
972}
973
974/// Drop the app handle without running. Safe to call on null.
975#[unsafe(no_mangle)]
976pub unsafe extern "C" fn lumen_app_free(app: *mut LumenApp) {
977    if app.is_null() {
978        return;
979    }
980    let _ = catch(|| {
981        unsafe {
982            drop(Box::from_raw(app));
983        }
984        LumenStatus::Ok
985    });
986}
987
988/// Consume the app handle and enter the Lumen event loop. Blocks
989/// until the window closes. After this returns, `app` is freed -
990/// do not call `lumen_app_free` on the same pointer.
991#[unsafe(no_mangle)]
992pub unsafe extern "C" fn lumen_app_run(app: *mut LumenApp) -> LumenStatus {
993    catch(|| {
994        if app.is_null() {
995            return LumenStatus::ErrInvalidHandle;
996        }
997        let app = unsafe { Box::from_raw(app) };
998        run_inner(*app)
999    })
1000}
1001
1002/// Consume the app handle and drive `ticks` main-schedule ticks without
1003/// opening a window or GPU surface (ABI 0.3). After this returns, `app`
1004/// is freed - do not call `lumen_app_free` on the same pointer.
1005///
1006/// This is the headless / CI entry point: it builds the full app (same
1007/// plugin stack, scripts, and reactive bindings as `lumen_app_run`) and
1008/// calls `App::tick()` `ticks` times, then returns. Signal round-trips,
1009/// script execution, `<for>` / `<if>` reconciliation, and typed-property
1010/// draining all run; there is no windowing, no input source, and no
1011/// GPU-backed rendering. Native click handlers registered with
1012/// `lumen_app_on_click` will not fire (no input is injected in headless
1013/// mode). Pass `ticks = 0` to build-and-drop (validates the app loads).
1014#[unsafe(no_mangle)]
1015pub unsafe extern "C" fn lumen_app_run_headless(app: *mut LumenApp, ticks: u32) -> LumenStatus {
1016    catch(|| {
1017        if app.is_null() {
1018            return LumenStatus::ErrInvalidHandle;
1019        }
1020        let app = unsafe { Box::from_raw(app) };
1021        run_headless_inner(*app, ticks)
1022    })
1023}
1024
1025/// Last error message set by any `lumen_*` call on this thread.
1026/// Returns null if no error has been recorded on this thread AND no
1027/// error has been recorded globally. The pointer is valid until the
1028/// next `lumen_*` call on this thread that produces an error.
1029///
1030/// W6.12 added a global fallback: when the thread-local slot is
1031/// empty (the common multi-thread embedder mistake of writing on
1032/// thread A and reading on thread B), we return the most recent
1033/// error from any thread instead of null.
1034#[unsafe(no_mangle)]
1035pub unsafe extern "C" fn lumen_last_error() -> *const c_char {
1036    let tls = LAST_ERROR.with(|c| {
1037        c.borrow()
1038            .as_ref()
1039            .map(|s| s.as_ptr())
1040            .unwrap_or(ptr::null())
1041    });
1042    if !tls.is_null() {
1043        return tls;
1044    }
1045    lumen_last_error_global()
1046}
1047
1048/// Returns the most recent error message recorded by any thread.
1049/// May return null if no error has ever been recorded. The returned
1050/// pointer is valid until the next `lumen_*` call anywhere in the
1051/// process that produces an error.
1052///
1053/// Distinguished from [`lumen_last_error`] for embedders that
1054/// explicitly want the global slot and don't want the TLS fallback.
1055#[unsafe(no_mangle)]
1056pub extern "C" fn lumen_last_error_global() -> *const c_char {
1057    // We cannot safely return a borrowed pointer out of the mutex
1058    // guard. Stash the CString in a separate thread-local "trampoline"
1059    // so the returned pointer outlives this function call.
1060    thread_local! {
1061        static GLOBAL_BUFFER: RefCell<Option<CString>> = const { RefCell::new(None) };
1062    }
1063    let snapshot: Option<CString> = match GLOBAL_LAST_ERROR.lock() {
1064        Ok(g) => g.clone(),
1065        Err(poisoned) => poisoned.into_inner().clone(),
1066    };
1067    GLOBAL_BUFFER.with(|cell| {
1068        *cell.borrow_mut() = snapshot;
1069        cell.borrow()
1070            .as_ref()
1071            .map(|s| s.as_ptr())
1072            .unwrap_or(ptr::null())
1073    })
1074}
1075
1076// ============================================================
1077// Internal: script <-> LumenValue marshaling
1078// ============================================================
1079
1080/// Translate the accumulated [`LumenApp`] configuration into a
1081/// [`RunOptions`], installing the exposed functions and the id-scoped native
1082/// click router. Shared by [`lumen_app_run`] (windowed) and
1083/// [`lumen_app_run_headless`].
1084fn build_run_options(app: LumenApp) -> RunOptions {
1085    let LumenApp {
1086        dir,
1087        artifact_bytes,
1088        title,
1089        size,
1090        exposed,
1091        click_handlers,
1092        close_handler,
1093        compiler_plugins,
1094    } = app;
1095
1096    // Two source shapes:
1097    //   - `artifact_bytes` present (link-not-embed launcher): run the prebuilt
1098    //     LMNA bytes with NO parser; `dir` is only the asset base directory.
1099    //   - otherwise (classic embed): inject the compiler's front-end so the
1100    //     runtime can parse markup / CSS from source.
1101    let mut opts = match artifact_bytes {
1102        Some(bytes) => RunOptions::new(&dir).with_artifact_bytes(bytes),
1103        // From-source embed path: inject the compiler's front-end so the
1104        // runtime can parse markup / CSS. Compiled only with `embed-parser`
1105        // (Part B): a trimmed static `--bundle` launcher drops lumenc and runs
1106        // prebuilt LMNA bytes only, so a from-source request without a parser
1107        // surfaces `RunError::ParserDisabled` at load.
1108        #[cfg(feature = "embed-parser")]
1109        None => RunOptions::new(&dir).with_parser(lumenc::default_parser()),
1110        #[cfg(not(feature = "embed-parser"))]
1111        None => RunOptions::new(&dir),
1112    };
1113    // The chain `lumen_app_new` resolved rides in with the parser.
1114    opts.compiler_plugins = compiler_plugins;
1115    if let Some(t) = title {
1116        opts.title = Some(t);
1117    }
1118    if let Some(sz) = size {
1119        opts.size = sz;
1120    }
1121
1122    for ef in exposed {
1123        let ExposedFn {
1124            name,
1125            fn_ptr,
1126            user_data,
1127            arg_count,
1128        } = ef;
1129        opts = opts.with_native_fn(ScriptFn::value(
1130            name,
1131            arg_count,
1132            move |args: &[ScriptValue]| {
1133                // Hold temporary CStrings until the call returns so
1134                // any string arg pointers stay valid.
1135                let mut keep: Vec<CString> = Vec::new();
1136                let lvs: Vec<LumenValue> = args
1137                    .iter()
1138                    .map(|v| script_value_to_lumen(v, &mut keep))
1139                    .collect();
1140                // Zero-initialise the out slot to nil so a callback that
1141                // leaves it untouched returns unit.
1142                let mut out = LumenValue {
1143                    kind: LumenKind::Nil,
1144                    as_: LumenValueData { integer: 0 },
1145                };
1146                unsafe {
1147                    fn_ptr(
1148                        &mut out,
1149                        lvs.len() as c_int,
1150                        lvs.as_ptr(),
1151                        user_data.as_ptr(),
1152                    );
1153                }
1154                lumen_to_script_value(&out)
1155            },
1156        ));
1157    }
1158
1159    // Id-scoped native click routing (ABI 0.3). Install a per-tick system
1160    // via an app hook that reads this tick's `ClickEvent`s, resolves each
1161    // target entity's `LumenId`, and calls the matching native handler.
1162    // Runs alongside (not instead of) any script-side `on_click(id)`.
1163    if !click_handlers.is_empty() {
1164        opts = opts.with_app_hook(move |app| {
1165            use lumen_core::prelude::{ClickEvent, LumenId, MessageReader, Query, TickStage};
1166            let handlers = click_handlers;
1167            app.add_systems(
1168                TickStage::Systems,
1169                move |mut clicks: MessageReader<ClickEvent>, ids: Query<&LumenId>| {
1170                    for click in clicks.read() {
1171                        let Ok(id) = ids.get(click.entity) else {
1172                            continue;
1173                        };
1174                        if let Some(&(cb, ud)) = handlers.get(id.0.as_str())
1175                            && let Ok(cid) = CString::new(id.0.as_str())
1176                        {
1177                            unsafe { cb(cid.as_ptr(), ud.as_ptr()) };
1178                        }
1179                    }
1180                },
1181            );
1182        });
1183    }
1184
1185    // App-level close hook (ABI 0.5). The window backend emits
1186    // `CloseRequest { vetoed: false }` on the OS close request and runs
1187    // one veto tick before tearing anything down; this system observes
1188    // the request during that tick, calls the native hook, and - when
1189    // the hook returns 0 - writes `CloseRequest { vetoed: true }` so the
1190    // backend keeps the window open (the same veto protocol app systems
1191    // and the script-side `on_close()` use). Reads the buffer through a
1192    // `MessageCursor` because the veto write needs `ResMut` access to
1193    // the same `Messages<CloseRequest>` resource.
1194    if let Some((close_cb, close_ud)) = close_handler {
1195        opts = opts.with_app_hook(move |app| {
1196            use bevy_ecs::message::{MessageCursor, Messages};
1197            use lumen_core::input::CloseRequest;
1198            use lumen_core::prelude::TickStage;
1199            let mut cursor = MessageCursor::<CloseRequest>::default();
1200            app.add_systems(
1201                TickStage::Systems,
1202                move |mut msgs: bevy_ecs::system::ResMut<Messages<CloseRequest>>| {
1203                    let requests = cursor.read(&msgs).filter(|ev| !ev.vetoed).count();
1204                    let mut veto = false;
1205                    for _ in 0..requests {
1206                        let allow = unsafe { close_cb(close_ud.as_ptr()) };
1207                        if allow == 0 {
1208                            veto = true;
1209                        }
1210                    }
1211                    if veto {
1212                        msgs.write(CloseRequest { vetoed: true });
1213                    }
1214                },
1215            );
1216        });
1217    }
1218
1219    // Signal-change subscription dispatch (ABI 0.4). Installs one late-tick
1220    // system reading the committed `PropertyStore`. It keeps a per-system
1221    // `last` map of the value it last delivered per name, so a change is any
1222    // committed value that differs from the previous delivery - the real
1223    // commit signal, coalesced per tick exactly like `PropertyStore`'s own
1224    // dirty/notify path (multiple mid-tick writes collapse to the final
1225    // value). No background thread: this runs inside the app's own tick.
1226    // Always installed; it early-returns when nothing is registered.
1227    opts = opts.with_app_hook(move |app| {
1228        use lumen_core::prelude::{PropertyKey, PropertyStore, PropertyValue, Res, TickStage};
1229        let mut last: HashMap<String, PropertyValue> = HashMap::new();
1230        app.add_systems(
1231            TickStage::A11ySync,
1232            move |store: Option<Res<PropertyStore>>| {
1233                let Some(store) = store else {
1234                    return;
1235                };
1236                // Determine which watched names changed, cloning the callback
1237                // set + new value out from under the registry lock so a
1238                // callback that re-enters `lumen_signal_watch` can't deadlock.
1239                let mut fires: Vec<(CString, PropertyValue, LumenWatchFn, UserData)> = Vec::new();
1240                {
1241                    let reg = signal_watchers().lock().unwrap_or_else(|e| e.into_inner());
1242                    if reg.is_empty() {
1243                        return;
1244                    }
1245                    for (name, entries) in reg.iter() {
1246                        let key = PropertyKey::Global(Arc::<str>::from(name.as_str()));
1247                        let Some(value) = store.get(&key) else {
1248                            continue;
1249                        };
1250                        let changed = match last.get(name) {
1251                            Some(prev) => !prev.eq_value(value),
1252                            None => true,
1253                        };
1254                        if changed {
1255                            last.insert(name.clone(), value.clone());
1256                            if let Ok(cname) = CString::new(name.as_str()) {
1257                                for (cb, ud) in entries {
1258                                    fires.push((cname.clone(), value.clone(), *cb, *ud));
1259                                }
1260                            }
1261                        }
1262                    }
1263                }
1264                for (cname, value, cb, ud) in fires {
1265                    let mut keep: Option<CString> = None;
1266                    let lv = property_to_lumen(&value, &mut keep);
1267                    unsafe { cb(cname.as_ptr(), &lv, ud.as_ptr()) };
1268                }
1269            },
1270        );
1271    });
1272
1273    opts
1274}
1275
1276fn run_inner(app: LumenApp) -> LumenStatus {
1277    let opts = build_run_options(app);
1278    match lumen_runtime::run_app(opts) {
1279        Ok(()) => LumenStatus::Ok,
1280        Err(e) => {
1281            set_last_error(format!("{e}"));
1282            classify_run_error(&e)
1283        }
1284    }
1285}
1286
1287fn run_headless_inner(app: LumenApp, ticks: u32) -> LumenStatus {
1288    let opts = build_run_options(app);
1289    match lumen_runtime::run_app_headless(opts, ticks) {
1290        Ok(()) => LumenStatus::Ok,
1291        Err(e) => {
1292            set_last_error(format!("{e}"));
1293            classify_run_error(&e)
1294        }
1295    }
1296}
1297
1298/// Map a [`RunError`] onto the richest [`LumenStatus`] variant.
1299///
1300/// One variant is matched outright rather than read: an app whose code is
1301/// still at its root reports the migration hint, and that hint names the
1302/// `.css` and `.lmn` files to move, which the text classifier below would
1303/// read as a stylesheet failure.
1304fn classify_run_error(e: &RunError) -> LumenStatus {
1305    match e {
1306        // The directory handed in is not an app directory this runtime can
1307        // resolve paths in.
1308        RunError::Layout(_) => LumenStatus::ErrBadPath,
1309        other => classify_runtime_error(&other.to_string()),
1310    }
1311}
1312
1313/// Map a `lumenc` runtime error message onto the richest [`LumenStatus`]
1314/// variant we can identify. This is best-effort textual classification;
1315/// `lumenc::Error` doesn't carry a stable kind discriminant today.
1316/// W6.12 picked these prefixes by walking lumenc's error-construction
1317/// sites; new error kinds default to [`LumenStatus::ErrRuntime`].
1318fn classify_runtime_error(msg: &str) -> LumenStatus {
1319    let m = msg.to_ascii_lowercase();
1320    if m.contains("css") || m.contains("stylesheet") {
1321        LumenStatus::ErrCss
1322    } else if m.contains("parse") || m.contains("xml") || m.contains("html") {
1323        LumenStatus::ErrParse
1324    } else if m.contains("asset") || m.contains("decode") || m.contains("png") || m.contains("svg")
1325    {
1326        LumenStatus::ErrAsset
1327    } else if m.contains("window") || m.contains("winit") || m.contains("surface") {
1328        LumenStatus::ErrWindow
1329    } else if m.contains("script") {
1330        LumenStatus::ErrScript
1331    } else if m.contains("io") || m.contains("file") || m.contains("read") || m.contains("write") {
1332        LumenStatus::ErrIo
1333    } else {
1334        LumenStatus::ErrRuntime
1335    }
1336}
1337
1338/// Borrow a script argument into a `LumenValue` for one callback dispatch.
1339/// Strings get a temporary `CString` stashed in `keep` so the C-side pointer
1340/// stays live for the duration of the FFI call.
1341///
1342/// Array and map arguments arrive as `LUMEN_NIL`: the C side reads borrowed
1343/// views, and building one would mean keeping a whole temporary tree alive
1344/// across the call. Return values carry both shapes (see
1345/// [`lumen_to_script_value`]).
1346fn script_value_to_lumen(v: &ScriptValue, keep: &mut Vec<CString>) -> LumenValue {
1347    match v {
1348        ScriptValue::I64(i) => LumenValue {
1349            kind: LumenKind::Int,
1350            as_: LumenValueData { integer: *i },
1351        },
1352        ScriptValue::F64(f) => LumenValue {
1353            kind: LumenKind::Float,
1354            as_: LumenValueData { float_: *f },
1355        },
1356        ScriptValue::Bool(b) => LumenValue {
1357            kind: LumenKind::Bool,
1358            as_: LumenValueData {
1359                boolean: *b as c_int,
1360            },
1361        },
1362        ScriptValue::Str(s) => {
1363            let cs = CString::new(s.as_str()).unwrap_or_default();
1364            let ptr = cs.as_ptr();
1365            keep.push(cs);
1366            LumenValue {
1367                kind: LumenKind::String,
1368                as_: LumenValueData { string: ptr },
1369            }
1370        }
1371        ScriptValue::Unit | ScriptValue::Array(_) | ScriptValue::Map(_) => LumenValue {
1372            kind: LumenKind::Nil,
1373            as_: LumenValueData { integer: 0 },
1374        },
1375    }
1376}
1377
1378/// Copy a `LumenValue` produced by C into an owned [`ScriptValue`] the script
1379/// host translates into its own value type. Arrays and maps recurse; nothing
1380/// on the Rust side keeps a pointer into the C-side buffer after this returns.
1381fn lumen_to_script_value(v: &LumenValue) -> ScriptValue {
1382    match v.kind {
1383        LumenKind::Nil => ScriptValue::Unit,
1384        LumenKind::Bool => ScriptValue::Bool(unsafe { v.as_.boolean } != 0),
1385        LumenKind::Int => ScriptValue::I64(unsafe { v.as_.integer }),
1386        LumenKind::Float => ScriptValue::F64(unsafe { v.as_.float_ }),
1387        LumenKind::String => {
1388            let p = unsafe { v.as_.string };
1389            if p.is_null() {
1390                ScriptValue::Str(String::new())
1391            } else {
1392                ScriptValue::Str(unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned())
1393            }
1394        }
1395        LumenKind::Array => {
1396            let view = unsafe { v.as_.array };
1397            let items: &[LumenValue] = if view.items.is_null() || view.len == 0 {
1398                &[]
1399            } else {
1400                unsafe { std::slice::from_raw_parts(view.items, view.len) }
1401            };
1402            ScriptValue::Array(items.iter().map(lumen_to_script_value).collect())
1403        }
1404        LumenKind::Map => {
1405            let view = unsafe { v.as_.map };
1406            let entries: &[LumenMapEntry] = if view.entries.is_null() || view.len == 0 {
1407                &[]
1408            } else {
1409                unsafe { std::slice::from_raw_parts(view.entries, view.len) }
1410            };
1411            let mut m = HashMap::with_capacity(entries.len());
1412            for e in entries {
1413                let key = if e.key.is_null() {
1414                    String::new()
1415                } else {
1416                    unsafe { CStr::from_ptr(e.key) }
1417                        .to_string_lossy()
1418                        .into_owned()
1419                };
1420                m.insert(key, lumen_to_script_value(&e.value));
1421            }
1422            ScriptValue::Map(m)
1423        }
1424    }
1425}
1426
1427// ============================================================
1428// Array read-back cache (ABI 0.3).
1429//
1430// `lumen_signal_set_array` / `lumen_signal_clear` write into Lumen's
1431// reactive store but have no read-back path of their own. This
1432// process-wide cache mirrors every FFI-originated array write so
1433// `lumen_signal_array_len` / `lumen_signal_array_get_field` can answer
1434// "what did I last push into this signal" from any thread, before or
1435// during a run - the same pre-run cache pattern the typed accessors use
1436// (`TYPED_SIGNALS`).
1437//
1438// Scope note (documented in the header + SDK READMEs): the array getters
1439// read back the value the *embedder* last pushed through the FFI. A write
1440// that originates inside the running app (a script `signals.x.set(..)` or
1441// a two-way input binding) lands in `ArraySignals` but is not mirrored
1442// here, so it is not visible to them. Reading live in-app state
1443// cross-thread would require sharing the running `App`'s world across the
1444// FFI (tracked with the typed-getter TODO).
1445// ============================================================
1446
1447/// One record-shaped array-signal row: field name -> stringified value.
1448type ArrayRow = HashMap<String, String>;
1449/// FFI-local mirror of every array signal the embedder has pushed.
1450type ArraySignalMap = HashMap<String, Vec<ArrayRow>>;
1451
1452static ARRAY_SIGNALS: OnceLock<Mutex<ArraySignalMap>> = OnceLock::new();
1453
1454fn array_signals() -> &'static Mutex<ArraySignalMap> {
1455    ARRAY_SIGNALS.get_or_init(|| Mutex::new(HashMap::new()))
1456}
1457
1458/// Copy `value` (UTF-8) into the caller buffer following the shared
1459/// string-out convention: on success writes the bytes plus a trailing
1460/// NUL and sets `*out_len` (when non-null) to the byte length excluding
1461/// the NUL. When the buffer is null or too small, sets `*out_len` to the
1462/// required capacity (byte length + 1 for the NUL) and returns
1463/// [`LumenStatus::ErrBufferTooSmall`] without touching `buf`.
1464fn write_string_out(
1465    value: &str,
1466    buf: *mut c_char,
1467    buf_len: usize,
1468    out_len: *mut usize,
1469) -> LumenStatus {
1470    let bytes = value.as_bytes();
1471    let needed = bytes.len() + 1; // include NUL
1472    if buf.is_null() || buf_len < needed {
1473        if !out_len.is_null() {
1474            unsafe { *out_len = needed };
1475        }
1476        return LumenStatus::ErrBufferTooSmall;
1477    }
1478    unsafe {
1479        ptr::copy_nonoverlapping(bytes.as_ptr(), buf as *mut u8, bytes.len());
1480        *buf.add(bytes.len()) = 0;
1481    }
1482    if !out_len.is_null() {
1483        unsafe { *out_len = bytes.len() };
1484    }
1485    LumenStatus::Ok
1486}
1487
1488// ============================================================
1489// File-based-pages navigation (ABI 0.6).
1490//
1491// Navigation is a command on the shared bus, not a per-language builtin:
1492// these exports write the reserved `route.request` cell through the same
1493// `lumen_core::nav` surface the scripts' `page()` builtin and the Rust SDK use,
1494// so every embedding (C/C++, Python ctypes, C# P/Invoke, plugins) reaches
1495// the ONE resolver. Thread-safe; callable before or during a run. The
1496// runtime resolves the target by longest existing `.lmn` prefix.
1497// ============================================================
1498
1499/// Navigate the active page to `path` (UTF-8, NUL-terminated). `path` is a
1500/// page path (`"settings"`, `"/user/7"`, `"/"`), resolved by longest
1501/// existing `.lmn` prefix - not a URL scheme. Equivalent to the script
1502/// `page("...")` command and the Rust SDK `Signals::navigate`. Thread-safe.
1503#[unsafe(no_mangle)]
1504pub unsafe extern "C" fn lumen_navigate(path: *const c_char) -> LumenStatus {
1505    catch(|| {
1506        if path.is_null() {
1507            set_last_error("lumen_navigate: null path");
1508            return LumenStatus::ErrBadArg;
1509        }
1510        let p = match unsafe { CStr::from_ptr(path) }.to_str() {
1511            Ok(s) => s.to_owned(),
1512            Err(_) => {
1513                set_last_error("lumen_navigate: path not utf-8");
1514                return LumenStatus::ErrBadArg;
1515            }
1516        };
1517        lumen_core::nav::navigate(p);
1518        LumenStatus::Ok
1519    })
1520}
1521
1522/// Step one entry back in the in-memory history stack (desktop). No-op at the
1523/// start of history. Thread-safe.
1524#[unsafe(no_mangle)]
1525pub unsafe extern "C" fn lumen_navigate_back() -> LumenStatus {
1526    catch(|| {
1527        lumen_core::nav::back();
1528        LumenStatus::Ok
1529    })
1530}
1531
1532/// Step one entry forward in the in-memory history stack (desktop). No-op at
1533/// the end of history. Thread-safe.
1534#[unsafe(no_mangle)]
1535pub unsafe extern "C" fn lumen_navigate_forward() -> LumenStatus {
1536    catch(|| {
1537        lumen_core::nav::forward();
1538        LumenStatus::Ok
1539    })
1540}
1541
1542/// Read the current active page key into `buf` (UTF-8 + trailing NUL),
1543/// following the shared string-out convention: on success `*out_len` (when
1544/// non-null) is the byte length excluding the NUL; when `buf` is null or too
1545/// small, `*out_len` is set to the required capacity and
1546/// [`LumenStatus::ErrBufferTooSmall`] is returned. Empty before the first
1547/// page mounts. Thread-safe (reads the `lumen_core::nav` current-page mirror,
1548/// which lags a resolved navigation by at most one tick).
1549#[unsafe(no_mangle)]
1550pub unsafe extern "C" fn lumen_current_page(
1551    buf: *mut c_char,
1552    buf_len: usize,
1553    out_len: *mut usize,
1554) -> LumenStatus {
1555    catch(|| write_string_out(&lumen_core::nav::current(), buf, buf_len, out_len))
1556}
1557
1558/// Replace the contents of an array signal. `value` must be a
1559/// `LUMEN_ARRAY` of `LUMEN_MAP` entries - each map becomes one row
1560/// (string->string after stringification) consumed by `<for>` markup.
1561/// Pointer is borrowed for the duration of the call; Lumen copies
1562/// immediately. Embedder may free buffers as soon as this returns.
1563#[unsafe(no_mangle)]
1564pub unsafe extern "C" fn lumen_signal_set_array(
1565    name: *const c_char,
1566    value: *const LumenValue,
1567) -> LumenStatus {
1568    catch(|| {
1569        if name.is_null() || value.is_null() {
1570            return LumenStatus::ErrBadArg;
1571        }
1572        let n = match unsafe { CStr::from_ptr(name) }.to_str() {
1573            Ok(s) => s.to_owned(),
1574            Err(_) => return LumenStatus::ErrBadArg,
1575        };
1576        let v = unsafe { &*value };
1577        if v.kind != LumenKind::Array {
1578            set_last_error("lumen_signal_set_array: value.kind must be LUMEN_ARRAY");
1579            return LumenStatus::ErrInvalidValue;
1580        }
1581        let arr = unsafe { v.as_.array };
1582        let items_slice: &[LumenValue] = if arr.items.is_null() || arr.len == 0 {
1583            &[]
1584        } else {
1585            unsafe { std::slice::from_raw_parts(arr.items, arr.len) }
1586        };
1587        let mut rows: Vec<HashMap<String, String>> = Vec::with_capacity(items_slice.len());
1588        for row in items_slice {
1589            let mut map: HashMap<String, String> = HashMap::new();
1590            if row.kind == LumenKind::Map {
1591                let mv = unsafe { row.as_.map };
1592                let entries: &[LumenMapEntry] = if mv.entries.is_null() || mv.len == 0 {
1593                    &[]
1594                } else {
1595                    unsafe { std::slice::from_raw_parts(mv.entries, mv.len) }
1596                };
1597                for e in entries {
1598                    let k = if e.key.is_null() {
1599                        String::new()
1600                    } else {
1601                        unsafe { CStr::from_ptr(e.key) }
1602                            .to_string_lossy()
1603                            .into_owned()
1604                    };
1605                    map.insert(k, stringify_lumen(&e.value));
1606                }
1607            }
1608            rows.push(map);
1609        }
1610        array_signals()
1611            .lock()
1612            .unwrap_or_else(|e| e.into_inner())
1613            .insert(n.clone(), rows.clone());
1614        push_external_array(n, rows);
1615        LumenStatus::Ok
1616    })
1617}
1618
1619/// Clear a signal (scalar => empty string, array => empty vec).
1620#[unsafe(no_mangle)]
1621pub unsafe extern "C" fn lumen_signal_clear(name: *const c_char) -> LumenStatus {
1622    catch(|| {
1623        if name.is_null() {
1624            return LumenStatus::ErrBadArg;
1625        }
1626        let n = match unsafe { CStr::from_ptr(name) }.to_str() {
1627            Ok(s) => s,
1628            Err(_) => return LumenStatus::ErrBadArg,
1629        };
1630        typed_signals()
1631            .lock()
1632            .unwrap_or_else(|e| e.into_inner())
1633            .insert(n.to_owned(), TypedSignalValue::Str(Arc::<str>::from("")));
1634        array_signals()
1635            .lock()
1636            .unwrap_or_else(|e| e.into_inner())
1637            .insert(n.to_owned(), Vec::new());
1638        push_external_clear(n);
1639        LumenStatus::Ok
1640    })
1641}
1642
1643/// Report the row count of an array signal (ABI 0.3).
1644///
1645/// Writes the number of rows the embedder last pushed through
1646/// `lumen_signal_set_array` (0 after a `clear`) into `*out_len`. Returns
1647/// [`LumenStatus::ErrBadArg`] when `name` / `out_len` is null, `name` is
1648/// non-UTF-8, or the array signal has never been set through the FFI.
1649#[unsafe(no_mangle)]
1650pub unsafe extern "C" fn lumen_signal_array_len(
1651    name: *const c_char,
1652    out_len: *mut usize,
1653) -> LumenStatus {
1654    catch(|| {
1655        let Some(n) = typed_signal_name(name) else {
1656            set_last_error("lumen_signal_array_len: null or non-utf8 name");
1657            return LumenStatus::ErrBadArg;
1658        };
1659        if out_len.is_null() {
1660            set_last_error("lumen_signal_array_len: null out_len");
1661            return LumenStatus::ErrBadArg;
1662        }
1663        let len = array_signals()
1664            .lock()
1665            .unwrap_or_else(|e| e.into_inner())
1666            .get(&n)
1667            .map(Vec::len);
1668        match len {
1669            Some(l) => {
1670                unsafe { *out_len = l };
1671                LumenStatus::Ok
1672            }
1673            None => {
1674                set_last_error("lumen_signal_array_len: no array signal by that name");
1675                LumenStatus::ErrBadArg
1676            }
1677        }
1678    })
1679}
1680
1681/// Read one field of one row of an array signal as a UTF-8 string
1682/// (ABI 0.3).
1683///
1684/// Rows are the record-shaped (field -> stringified value) maps pushed
1685/// through `lumen_signal_set_array`. Looks up `row`-th row's `field`
1686/// entry and copies it out following the same buffer convention as
1687/// [`lumen_signal_get_str`] (NUL-terminated; `ErrBufferTooSmall` with
1688/// `*out_len` = required capacity when `buf` is too small).
1689///
1690/// Returns [`LumenStatus::ErrBadArg`] when `name` / `field` is null or
1691/// non-UTF-8, the array signal is absent, `row` is out of range, or the
1692/// row has no such field.
1693#[unsafe(no_mangle)]
1694pub unsafe extern "C" fn lumen_signal_array_get_field(
1695    name: *const c_char,
1696    row: usize,
1697    field: *const c_char,
1698    buf: *mut c_char,
1699    buf_len: usize,
1700    out_len: *mut usize,
1701) -> LumenStatus {
1702    catch(|| {
1703        let Some(n) = typed_signal_name(name) else {
1704            set_last_error("lumen_signal_array_get_field: null or non-utf8 name");
1705            return LumenStatus::ErrBadArg;
1706        };
1707        let Some(key) = typed_signal_name(field) else {
1708            set_last_error("lumen_signal_array_get_field: null or non-utf8 field");
1709            return LumenStatus::ErrBadArg;
1710        };
1711        let value = {
1712            let guard = array_signals().lock().unwrap_or_else(|e| e.into_inner());
1713            let Some(rows) = guard.get(&n) else {
1714                set_last_error("lumen_signal_array_get_field: no array signal by that name");
1715                return LumenStatus::ErrBadArg;
1716            };
1717            let Some(row_map) = rows.get(row) else {
1718                set_last_error("lumen_signal_array_get_field: row index out of range");
1719                return LumenStatus::ErrBadArg;
1720            };
1721            row_map.get(&key).cloned()
1722        };
1723        match value {
1724            Some(v) => write_string_out(&v, buf, buf_len, out_len),
1725            None => {
1726                set_last_error("lumen_signal_array_get_field: no such field in row");
1727                LumenStatus::ErrBadArg
1728            }
1729        }
1730    })
1731}
1732
1733fn stringify_lumen(v: &LumenValue) -> String {
1734    match v.kind {
1735        LumenKind::Nil => String::new(),
1736        LumenKind::Bool => (unsafe { v.as_.boolean } != 0).to_string(),
1737        LumenKind::Int => unsafe { v.as_.integer }.to_string(),
1738        LumenKind::Float => format!("{}", unsafe { v.as_.float_ }),
1739        LumenKind::String => {
1740            let p = unsafe { v.as_.string };
1741            if p.is_null() {
1742                String::new()
1743            } else {
1744                unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned()
1745            }
1746        }
1747        LumenKind::Array | LumenKind::Map => String::new(),
1748    }
1749}
1750
1751// ============================================================
1752// Typed scalar accessors - the one scalar signal family.
1753//
1754// Every scalar signal is set and read through a `set_<type>` /
1755// `get_<type>` pair keyed by name alone: string, int64, float64, bool,
1756// color. The setters push directly into the foundation
1757// `PropertyStore` via
1758// `lumen_core::property_store::push_external_property`. The receiving
1759// `drain_external_properties` system (installed by `App::new()` in
1760// `TickStage::CommandDrain`) lands the typed
1761// `PropertyValue::Str` / `I64` / `F64` / `Bool` / `Color` cell on the
1762// next tick - no stringify-on-write, no parse-on-read. `bind-text`
1763// markup reads the same cell, stringifying scalars on read, so a
1764// bound element reflects a typed write on the next tick.
1765//
1766// The accessors keep a thread-safe in-process map (`TYPED_SIGNALS`)
1767// as a *pre-run cache*: embedders that call `lumen_signal_set_int64`
1768// before `lumen_app_run` configure the seed values here, and the
1769// read path consults the cache first (so reads work before the
1770// PropertyStore exists). After `lumen_app_run` starts pushing the
1771// queued external writes into the live store, the cache continues
1772// to serve as the read-back surface (writes mirror into both).
1773//
1774// Architectural compromise: the FFI typed get exports cannot trivially
1775// take a read lock on the running `App`'s `PropertyStore` resource
1776// because the App is consumed by `lumen_app_run` and owned by the
1777// winit event loop for the duration of the run. Sharing a
1778// `Send + Sync` World handle across the FFI would require touching
1779// the `lumenc` runtime and the `lumen-core::app::App` ownership
1780// surface; both are out of scope for this round. The pragmatic
1781// alternative the code below implements:
1782//
1783//   - typed setters mirror to BOTH the `TYPED_SIGNALS` cache AND the
1784//     external typed-property channel (pre-run pushes get drained
1785//     once the App ticks);
1786//   - typed getters consult `external_property_snapshot()` first
1787//     (catches pending pre-run writes that haven't been drained yet),
1788//     then fall back to the `TYPED_SIGNALS` cache;
1789//   - the cache is updated by every typed set so embedders that
1790//     never call `lumen_app_run` (test harnesses, headless probes)
1791//     still see a working round-trip.
1792//
1793// TODO(round-5): expose `Arc<RwLock<App>>` from `lumen`'s
1794// `lumen_app_run` so post-run reads can hit the live `PropertyStore`
1795// directly without the channel snapshot dance. Tracked in TODO.md.
1796//
1797// Every accessor is keyed by signal name alone. Signals are global, so
1798// there is no app handle to pass and none of these take one.
1799// ============================================================
1800
1801use std::sync::OnceLock;
1802
1803#[derive(Clone, Debug)]
1804enum TypedSignalValue {
1805    Str(Arc<str>),
1806    Int64(i64),
1807    Float64(f64),
1808    Bool(bool),
1809    Color([u8; 4]),
1810}
1811
1812static TYPED_SIGNALS: OnceLock<Mutex<HashMap<String, TypedSignalValue>>> = OnceLock::new();
1813
1814fn typed_signals() -> &'static Mutex<HashMap<String, TypedSignalValue>> {
1815    TYPED_SIGNALS.get_or_init(|| Mutex::new(HashMap::new()))
1816}
1817
1818fn typed_signal_name(name: *const c_char) -> Option<String> {
1819    if name.is_null() {
1820        return None;
1821    }
1822    let s = unsafe { CStr::from_ptr(name) }.to_str().ok()?;
1823    Some(s.to_owned())
1824}
1825
1826/// Build a `PropertyKey::Global` from an interned `Arc<str>`. Helper used by
1827/// every typed setter so the channel send doesn't re-allocate.
1828fn global_key(name: &str) -> PropertyKey {
1829    PropertyKey::Global(Arc::<str>::from(name))
1830}
1831
1832/// Read a typed value, consulting three caches in order:
1833///
1834/// 1. [`lumen_core::property_store::typed_property_snapshot`] -
1835///    the post-tick mirror of [`PropertyStore`]. Sees writes from any
1836///    source (ECS, script, FFI) that committed during the previous
1837///    tick. This is the authoritative post-run path.
1838/// 2. [`external_property_snapshot`] - pending bus writes not yet
1839///    drained into [`PropertyStore`]. Covers FFI typed-setter writes
1840///    that happened mid-tick before the next drain runs.
1841/// 3. The local `TYPED_SIGNALS` cache - authoritative pre-run, when
1842///    no App has been built yet. Setter writes seed this immediately.
1843///
1844/// Returns `None` when none of the three holds the key.
1845fn typed_read(name: &str) -> Option<TypedSignalValue> {
1846    let key = global_key(name);
1847    // (1) Post-tick mirror - sees every PropertyStore typed cell.
1848    if let Some(value) = lumen_core::property_store::typed_property_snapshot().remove(&key) {
1849        return Some(TypedSignalValue::from(value));
1850    }
1851    // (2) In-flight bus writes not yet drained.
1852    if let Some(value) = external_property_snapshot().remove(&key) {
1853        return Some(TypedSignalValue::from(value));
1854    }
1855    // (3) Pre-run / local cache.
1856    typed_signals()
1857        .lock()
1858        .unwrap_or_else(|e| e.into_inner())
1859        .get(name)
1860        .cloned()
1861}
1862
1863impl From<PropertyValue> for TypedSignalValue {
1864    fn from(v: PropertyValue) -> Self {
1865        match v {
1866            PropertyValue::Str(s) => Self::Str(s),
1867            PropertyValue::I64(n) => Self::Int64(n),
1868            PropertyValue::F64(n) => Self::Float64(n),
1869            PropertyValue::Bool(b) => Self::Bool(b),
1870            PropertyValue::Color(c) => Self::Color(c.to_rgba8()),
1871            // Vec2 and Custom have no scalar accessor - they fall back to
1872            // a sentinel, and the match arms in each `lumen_signal_get_*`
1873            // report the mismatch as ErrBadArg.
1874            PropertyValue::Vec2(_) | PropertyValue::Custom(_) => Self::Bool(false),
1875        }
1876    }
1877}
1878
1879/// Set a scalar signal to a UTF-8 string. A null `value` writes an empty
1880/// string. Thread-safe.
1881///
1882/// Pushes a `PropertyValue::Str` through the foundation typed-property
1883/// bus, so `bind-text="..."` markup observes the new string on the next
1884/// tick. Mirrors the write into the FFI-local cache for pre-run
1885/// read-back.
1886#[unsafe(no_mangle)]
1887pub unsafe extern "C" fn lumen_signal_set_str(
1888    name: *const c_char,
1889    value: *const c_char,
1890) -> LumenStatus {
1891    catch(|| {
1892        let Some(n) = typed_signal_name(name) else {
1893            set_last_error("lumen_signal_set_str: null or non-utf8 name");
1894            return LumenStatus::ErrBadArg;
1895        };
1896        let v: Arc<str> = if value.is_null() {
1897            Arc::<str>::from("")
1898        } else {
1899            match unsafe { CStr::from_ptr(value) }.to_str() {
1900                Ok(s) => Arc::<str>::from(s),
1901                Err(_) => {
1902                    set_last_error("lumen_signal_set_str: value is not utf-8");
1903                    return LumenStatus::ErrBadArg;
1904                }
1905            }
1906        };
1907        typed_signals()
1908            .lock()
1909            .unwrap_or_else(|e| e.into_inner())
1910            .insert(n.clone(), TypedSignalValue::Str(Arc::clone(&v)));
1911        push_external_property(global_key(&n), PropertyValue::Str(v));
1912        LumenStatus::Ok
1913    })
1914}
1915
1916/// Read a scalar signal as a UTF-8 string into a caller-provided buffer.
1917///
1918/// On success copies the value plus a trailing NUL into `buf` and, when
1919/// `out_len` is non-null, sets `*out_len` to the byte length (excluding
1920/// the NUL). When `buf` is null or `buf_len` is too small, sets
1921/// `*out_len` to the required capacity (byte length + 1) and returns
1922/// [`LumenStatus::ErrBufferTooSmall`] without writing `buf`; call once
1923/// with a null/zero buffer to size it, then again to fill.
1924///
1925/// Returns [`LumenStatus::ErrBadArg`] when `name` is null / non-UTF-8, or
1926/// when the signal holds no string.
1927///
1928/// Scope: this reads back the string the embedder last pushed through
1929/// the FFI (a `clear` leaves an empty string). A string written inside
1930/// the running app lands in `PropertyStore`, which the cross-thread
1931/// mirror keeps for numbers, bools, and colors only, so it is not
1932/// visible here.
1933#[unsafe(no_mangle)]
1934pub unsafe extern "C" fn lumen_signal_get_str(
1935    name: *const c_char,
1936    buf: *mut c_char,
1937    buf_len: usize,
1938    out_len: *mut usize,
1939) -> LumenStatus {
1940    catch(|| {
1941        let Some(n) = typed_signal_name(name) else {
1942            set_last_error("lumen_signal_get_str: null or non-utf8 name");
1943            return LumenStatus::ErrBadArg;
1944        };
1945        match typed_read(&n) {
1946            Some(TypedSignalValue::Str(s)) => write_string_out(&s, buf, buf_len, out_len),
1947            _ => {
1948                set_last_error("lumen_signal_get_str: no string signal by that name");
1949                LumenStatus::ErrBadArg
1950            }
1951        }
1952    })
1953}
1954
1955/// Set a scalar signal to a 64-bit signed integer, typed.
1956///
1957/// Pushes a `PropertyValue::I64` through the foundation typed-property
1958/// bus so the receiving cell in `PropertyStore` keeps the typed variant
1959/// (no stringify-on-write, no parse-on-read). Mirrors the write into the
1960/// FFI-local cache for pre-run read-back.
1961#[unsafe(no_mangle)]
1962pub unsafe extern "C" fn lumen_signal_set_int64(name: *const c_char, value: i64) -> LumenStatus {
1963    catch(|| {
1964        let Some(n) = typed_signal_name(name) else {
1965            set_last_error("lumen_signal_set_int64: null or non-utf8 name");
1966            return LumenStatus::ErrBadArg;
1967        };
1968        typed_signals()
1969            .lock()
1970            .unwrap_or_else(|e| e.into_inner())
1971            .insert(n.clone(), TypedSignalValue::Int64(value));
1972        push_external_property(global_key(&n), PropertyValue::I64(value));
1973        LumenStatus::Ok
1974    })
1975}
1976
1977/// Read a scalar signal as a 64-bit signed integer, typed. Returns
1978/// [`LumenStatus::ErrBadArg`] when the signal holds no number.
1979///
1980/// Peeks the foundation typed-property bus snapshot first (catches
1981/// pending pre-run writes that haven't been drained yet) before falling
1982/// back to the local cache.
1983#[unsafe(no_mangle)]
1984pub unsafe extern "C" fn lumen_signal_get_int64(name: *const c_char, out: *mut i64) -> LumenStatus {
1985    catch(|| {
1986        let Some(n) = typed_signal_name(name) else {
1987            set_last_error("lumen_signal_get_int64: null or non-utf8 name");
1988            return LumenStatus::ErrBadArg;
1989        };
1990        if out.is_null() {
1991            set_last_error("lumen_signal_get_int64: null out pointer");
1992            return LumenStatus::ErrBadArg;
1993        }
1994        match typed_read(&n) {
1995            Some(TypedSignalValue::Int64(v)) => {
1996                unsafe { *out = v };
1997                LumenStatus::Ok
1998            }
1999            Some(TypedSignalValue::Float64(v)) => {
2000                unsafe { *out = v as i64 };
2001                LumenStatus::Ok
2002            }
2003            Some(TypedSignalValue::Bool(b)) => {
2004                unsafe { *out = b as i64 };
2005                LumenStatus::Ok
2006            }
2007            _ => LumenStatus::ErrBadArg,
2008        }
2009    })
2010}
2011
2012/// Set a scalar signal to an IEEE-754 double, typed.
2013///
2014/// Pushes `PropertyValue::F64` through the typed-property bus so the
2015/// `PropertyStore` cell receives the typed variant directly.
2016#[unsafe(no_mangle)]
2017pub unsafe extern "C" fn lumen_signal_set_float64(name: *const c_char, value: f64) -> LumenStatus {
2018    catch(|| {
2019        let Some(n) = typed_signal_name(name) else {
2020            set_last_error("lumen_signal_set_float64: null or non-utf8 name");
2021            return LumenStatus::ErrBadArg;
2022        };
2023        typed_signals()
2024            .lock()
2025            .unwrap_or_else(|e| e.into_inner())
2026            .insert(n.clone(), TypedSignalValue::Float64(value));
2027        push_external_property(global_key(&n), PropertyValue::F64(value));
2028        LumenStatus::Ok
2029    })
2030}
2031
2032/// Read a scalar signal as an IEEE-754 double, typed.
2033#[unsafe(no_mangle)]
2034pub unsafe extern "C" fn lumen_signal_get_float64(
2035    name: *const c_char,
2036    out: *mut f64,
2037) -> LumenStatus {
2038    catch(|| {
2039        let Some(n) = typed_signal_name(name) else {
2040            set_last_error("lumen_signal_get_float64: null or non-utf8 name");
2041            return LumenStatus::ErrBadArg;
2042        };
2043        if out.is_null() {
2044            set_last_error("lumen_signal_get_float64: null out pointer");
2045            return LumenStatus::ErrBadArg;
2046        }
2047        match typed_read(&n) {
2048            Some(TypedSignalValue::Float64(v)) => {
2049                unsafe { *out = v };
2050                LumenStatus::Ok
2051            }
2052            Some(TypedSignalValue::Int64(v)) => {
2053                unsafe { *out = v as f64 };
2054                LumenStatus::Ok
2055            }
2056            _ => LumenStatus::ErrBadArg,
2057        }
2058    })
2059}
2060
2061/// Set a scalar signal to a boolean, typed.
2062///
2063/// Pushes `PropertyValue::Bool` through the typed-property bus so the
2064/// `PropertyStore` cell receives the typed variant directly.
2065#[unsafe(no_mangle)]
2066pub unsafe extern "C" fn lumen_signal_set_bool(name: *const c_char, value: bool) -> LumenStatus {
2067    catch(|| {
2068        let Some(n) = typed_signal_name(name) else {
2069            set_last_error("lumen_signal_set_bool: null or non-utf8 name");
2070            return LumenStatus::ErrBadArg;
2071        };
2072        typed_signals()
2073            .lock()
2074            .unwrap_or_else(|e| e.into_inner())
2075            .insert(n.clone(), TypedSignalValue::Bool(value));
2076        push_external_property(global_key(&n), PropertyValue::Bool(value));
2077        LumenStatus::Ok
2078    })
2079}
2080
2081/// Read a scalar signal as a boolean, typed.
2082#[unsafe(no_mangle)]
2083pub unsafe extern "C" fn lumen_signal_get_bool(name: *const c_char, out: *mut bool) -> LumenStatus {
2084    catch(|| {
2085        let Some(n) = typed_signal_name(name) else {
2086            set_last_error("lumen_signal_get_bool: null or non-utf8 name");
2087            return LumenStatus::ErrBadArg;
2088        };
2089        if out.is_null() {
2090            set_last_error("lumen_signal_get_bool: null out pointer");
2091            return LumenStatus::ErrBadArg;
2092        }
2093        match typed_read(&n) {
2094            Some(TypedSignalValue::Bool(b)) => {
2095                unsafe { *out = b };
2096                LumenStatus::Ok
2097            }
2098            Some(TypedSignalValue::Int64(v)) => {
2099                unsafe { *out = v != 0 };
2100                LumenStatus::Ok
2101            }
2102            _ => LumenStatus::ErrBadArg,
2103        }
2104    })
2105}
2106
2107/// Set a scalar signal to a 4-byte RGBA color (each channel in 0..=255).
2108/// `rgba` must point to at least 4 bytes (`R`, `G`, `B`, `A`).
2109///
2110/// Pushes `PropertyValue::Color` (channels normalised to `[0, 1]`
2111/// floats) through the typed-property bus.
2112#[unsafe(no_mangle)]
2113pub unsafe extern "C" fn lumen_signal_set_color(
2114    name: *const c_char,
2115    rgba: *const u8,
2116) -> LumenStatus {
2117    catch(|| {
2118        let Some(n) = typed_signal_name(name) else {
2119            set_last_error("lumen_signal_set_color: null or non-utf8 name");
2120            return LumenStatus::ErrBadArg;
2121        };
2122        if rgba.is_null() {
2123            set_last_error("lumen_signal_set_color: null rgba pointer");
2124            return LumenStatus::ErrBadArg;
2125        }
2126        let bytes = unsafe { std::slice::from_raw_parts(rgba, 4) };
2127        let arr = [bytes[0], bytes[1], bytes[2], bytes[3]];
2128        typed_signals()
2129            .lock()
2130            .unwrap_or_else(|e| e.into_inner())
2131            .insert(n.clone(), TypedSignalValue::Color(arr));
2132        let color = Color::rgba(
2133            (arr[0] as f32) / 255.0,
2134            (arr[1] as f32) / 255.0,
2135            (arr[2] as f32) / 255.0,
2136            (arr[3] as f32) / 255.0,
2137        );
2138        push_external_property(global_key(&n), PropertyValue::Color(color));
2139        LumenStatus::Ok
2140    })
2141}
2142
2143/// Read a scalar signal as a 4-byte RGBA color. `out` must point to at
2144/// least 4 writable bytes.
2145#[unsafe(no_mangle)]
2146pub unsafe extern "C" fn lumen_signal_get_color(name: *const c_char, out: *mut u8) -> LumenStatus {
2147    catch(|| {
2148        let Some(n) = typed_signal_name(name) else {
2149            set_last_error("lumen_signal_get_color: null or non-utf8 name");
2150            return LumenStatus::ErrBadArg;
2151        };
2152        if out.is_null() {
2153            set_last_error("lumen_signal_get_color: null out pointer");
2154            return LumenStatus::ErrBadArg;
2155        }
2156        match typed_read(&n) {
2157            Some(TypedSignalValue::Color(c)) => {
2158                let dst = unsafe { std::slice::from_raw_parts_mut(out, 4) };
2159                dst.copy_from_slice(&c);
2160                LumenStatus::Ok
2161            }
2162            _ => LumenStatus::ErrBadArg,
2163        }
2164    })
2165}
2166
2167/// Returns a static, NUL-terminated UTF-8 description of `status`. Useful for
2168/// log messages on a non-OK return without an `lumen_last_error` round-trip
2169/// (which carries the thread-local context message instead of the status
2170/// enum's canonical name). The returned pointer lives for the program's
2171/// lifetime; callers must not free it.
2172#[unsafe(no_mangle)]
2173pub extern "C" fn lumen_status_message(status: LumenStatus) -> *const c_char {
2174    let s: &'static [u8] = match status {
2175        LumenStatus::Ok => b"ok\0",
2176        LumenStatus::ErrBadPath => b"bad path argument\0",
2177        LumenStatus::ErrBadArg => b"bad argument\0",
2178        LumenStatus::ErrRuntime => b"runtime error\0",
2179        LumenStatus::ErrInternal => b"internal error\0",
2180        LumenStatus::ErrParse => b"parse error\0",
2181        LumenStatus::ErrCss => b"css error\0",
2182        LumenStatus::ErrAsset => b"asset error\0",
2183        LumenStatus::ErrWindow => b"window backend error\0",
2184        LumenStatus::ErrScript => b"script error\0",
2185        LumenStatus::ErrIo => b"io error\0",
2186        LumenStatus::ErrInvalidHandle => b"invalid handle\0",
2187        LumenStatus::ErrInvalidValue => b"invalid value\0",
2188        LumenStatus::ErrPanic => b"rust panic across ffi\0",
2189        LumenStatus::ErrBufferTooSmall => b"output buffer too small\0",
2190    };
2191    s.as_ptr() as *const c_char
2192}
2193
2194// ============================================================
2195// Dynamic DOM read side (ABI 0.8): query / get_by_id / traversal
2196//
2197// A `LumenNode` is a packed handle (`0` = no node). All calls read the
2198// process-shared per-tick DOM snapshot the runtime publishes each frame,
2199// so they take no `LumenApp` handle (matching `lumen_navigate`). Selector
2200// grammar is the CSS Selectors-4 subset the cascade matcher accepts.
2201// ============================================================
2202
2203/// Opaque packed node handle. `0` means "no node".
2204pub type LumenNode = u64;
2205
2206/// Owned list of node handles returned by a query / children call. Free
2207/// with [`lumen_nodelist_free`]; index with [`lumen_nodelist_get`].
2208#[repr(C)]
2209pub struct LumenNodeList {
2210    /// Heap pointer to `len` contiguous [`LumenNode`] handles, or null
2211    /// when `len == 0`.
2212    pub ptr: *mut LumenNode,
2213    /// Number of handles.
2214    pub len: usize,
2215}
2216
2217fn empty_node_list() -> LumenNodeList {
2218    LumenNodeList {
2219        ptr: ptr::null_mut(),
2220        len: 0,
2221    }
2222}
2223
2224fn node_list_from(nodes: Vec<u64>) -> LumenNodeList {
2225    if nodes.is_empty() {
2226        return empty_node_list();
2227    }
2228    let mut boxed = nodes.into_boxed_slice();
2229    let list = LumenNodeList {
2230        ptr: boxed.as_mut_ptr(),
2231        len: boxed.len(),
2232    };
2233    std::mem::forget(boxed);
2234    list
2235}
2236
2237fn ffi_selector(selector: *const c_char, ctx: &str) -> Result<String, LumenStatus> {
2238    if selector.is_null() {
2239        set_last_error(format!("{ctx}: null selector"));
2240        return Err(LumenStatus::ErrBadArg);
2241    }
2242    match unsafe { CStr::from_ptr(selector) }.to_str() {
2243        Ok(s) => Ok(s.to_owned()),
2244        Err(_) => {
2245            set_last_error(format!("{ctx}: selector not utf-8"));
2246            Err(LumenStatus::ErrBadArg)
2247        }
2248    }
2249}
2250
2251/// Run a CSS selector against the current DOM snapshot, writing the
2252/// matches (document order) into `*out_list`. On success the caller owns
2253/// the list and must release it with [`lumen_nodelist_free`]. A bad
2254/// selector returns [`LumenStatus::ErrCss`]. Thread-safe.
2255#[unsafe(no_mangle)]
2256pub unsafe extern "C" fn lumen_query(
2257    selector: *const c_char,
2258    out_list: *mut LumenNodeList,
2259) -> LumenStatus {
2260    catch(|| {
2261        if out_list.is_null() {
2262            set_last_error("lumen_query: null out_list");
2263            return LumenStatus::ErrBadArg;
2264        }
2265        let sel = match ffi_selector(selector, "lumen_query") {
2266            Ok(s) => s,
2267            Err(status) => return status,
2268        };
2269        match lumen_script::node_query::run_query(&sel) {
2270            Ok(q) => {
2271                unsafe { *out_list = node_list_from(q.nodes) };
2272                LumenStatus::Ok
2273            }
2274            Err(e) => {
2275                set_last_error(format!("lumen_query: {e}"));
2276                LumenStatus::ErrCss
2277            }
2278        }
2279    })
2280}
2281
2282/// Number of matches for `selector`, written to `*out_len`. Thread-safe.
2283#[unsafe(no_mangle)]
2284pub unsafe extern "C" fn lumen_query_len(
2285    selector: *const c_char,
2286    out_len: *mut usize,
2287) -> LumenStatus {
2288    catch(|| {
2289        if out_len.is_null() {
2290            set_last_error("lumen_query_len: null out_len");
2291            return LumenStatus::ErrBadArg;
2292        }
2293        let sel = match ffi_selector(selector, "lumen_query_len") {
2294            Ok(s) => s,
2295            Err(status) => return status,
2296        };
2297        match lumen_script::node_query::run_query(&sel) {
2298            Ok(q) => {
2299                unsafe { *out_len = q.len() };
2300                LumenStatus::Ok
2301            }
2302            Err(e) => {
2303                set_last_error(format!("lumen_query_len: {e}"));
2304                LumenStatus::ErrCss
2305            }
2306        }
2307    })
2308}
2309
2310/// Bevy `single()` contract: succeed only when `selector` matches exactly
2311/// one node, writing it to `*out`. Zero or many matches returns
2312/// [`LumenStatus::ErrBadArg`] (and sets `*out` to `0`). Thread-safe.
2313#[unsafe(no_mangle)]
2314pub unsafe extern "C" fn lumen_query_single(
2315    selector: *const c_char,
2316    out: *mut LumenNode,
2317) -> LumenStatus {
2318    catch(|| {
2319        if out.is_null() {
2320            set_last_error("lumen_query_single: null out");
2321            return LumenStatus::ErrBadArg;
2322        }
2323        unsafe { *out = 0 };
2324        let sel = match ffi_selector(selector, "lumen_query_single") {
2325            Ok(s) => s,
2326            Err(status) => return status,
2327        };
2328        match lumen_script::node_query::run_query(&sel) {
2329            Ok(q) => match q.single() {
2330                Ok(node) => {
2331                    unsafe { *out = node };
2332                    LumenStatus::Ok
2333                }
2334                Err(msg) => {
2335                    set_last_error(format!("lumen_query_single: {msg}"));
2336                    LumenStatus::ErrBadArg
2337                }
2338            },
2339            Err(e) => {
2340                set_last_error(format!("lumen_query_single: {e}"));
2341                LumenStatus::ErrCss
2342            }
2343        }
2344    })
2345}
2346
2347/// Fast id lookup. Writes the matching node to `*out`, or `0` when no
2348/// element carries `id`. Thread-safe.
2349#[unsafe(no_mangle)]
2350pub unsafe extern "C" fn lumen_get_by_id(id: *const c_char, out: *mut LumenNode) -> LumenStatus {
2351    catch(|| {
2352        if out.is_null() {
2353            set_last_error("lumen_get_by_id: null out");
2354            return LumenStatus::ErrBadArg;
2355        }
2356        let id = match ffi_selector(id, "lumen_get_by_id") {
2357            Ok(s) => s,
2358            Err(status) => return status,
2359        };
2360        unsafe { *out = lumen_script::node_query::run_get_by_id(&id).unwrap_or(0) };
2361        LumenStatus::Ok
2362    })
2363}
2364
2365/// Write the document root node to `*out` (`0` before the first tick).
2366/// Thread-safe.
2367#[unsafe(no_mangle)]
2368pub unsafe extern "C" fn lumen_document(out: *mut LumenNode) -> LumenStatus {
2369    catch(|| {
2370        if out.is_null() {
2371            set_last_error("lumen_document: null out");
2372            return LumenStatus::ErrBadArg;
2373        }
2374        unsafe { *out = lumen_script::node_query::run_document().unwrap_or(0) };
2375        LumenStatus::Ok
2376    })
2377}
2378
2379/// Shared body for the single-handle traversal getters: resolve `node`,
2380/// apply `f`, write the result (`0` when absent) to `*out`.
2381fn node_relation(
2382    node: LumenNode,
2383    out: *mut LumenNode,
2384    ctx: &str,
2385    f: impl FnOnce(u64) -> Option<u64>,
2386) -> LumenStatus {
2387    if out.is_null() {
2388        set_last_error(format!("{ctx}: null out"));
2389        return LumenStatus::ErrBadArg;
2390    }
2391    unsafe { *out = f(node).unwrap_or(0) };
2392    LumenStatus::Ok
2393}
2394
2395/// Parent of `node` (`0` for a root or unknown handle). Thread-safe.
2396#[unsafe(no_mangle)]
2397pub unsafe extern "C" fn lumen_node_parent(node: LumenNode, out: *mut LumenNode) -> LumenStatus {
2398    catch(|| {
2399        node_relation(
2400            node,
2401            out,
2402            "lumen_node_parent",
2403            lumen_script::node_query::node_parent,
2404        )
2405    })
2406}
2407
2408/// First child of `node` (`0` when none). Thread-safe.
2409#[unsafe(no_mangle)]
2410pub unsafe extern "C" fn lumen_node_first_child(
2411    node: LumenNode,
2412    out: *mut LumenNode,
2413) -> LumenStatus {
2414    catch(|| {
2415        node_relation(
2416            node,
2417            out,
2418            "lumen_node_first_child",
2419            lumen_script::node_query::node_first_child,
2420        )
2421    })
2422}
2423
2424/// Last child of `node` (`0` when none). Thread-safe.
2425#[unsafe(no_mangle)]
2426pub unsafe extern "C" fn lumen_node_last_child(
2427    node: LumenNode,
2428    out: *mut LumenNode,
2429) -> LumenStatus {
2430    catch(|| {
2431        node_relation(
2432            node,
2433            out,
2434            "lumen_node_last_child",
2435            lumen_script::node_query::node_last_child,
2436        )
2437    })
2438}
2439
2440/// Next sibling of `node` (`0` when none). Thread-safe.
2441#[unsafe(no_mangle)]
2442pub unsafe extern "C" fn lumen_node_next(node: LumenNode, out: *mut LumenNode) -> LumenStatus {
2443    catch(|| {
2444        node_relation(
2445            node,
2446            out,
2447            "lumen_node_next",
2448            lumen_script::node_query::node_next,
2449        )
2450    })
2451}
2452
2453/// Previous sibling of `node` (`0` when none). Thread-safe.
2454#[unsafe(no_mangle)]
2455pub unsafe extern "C" fn lumen_node_prev(node: LumenNode, out: *mut LumenNode) -> LumenStatus {
2456    catch(|| {
2457        node_relation(
2458            node,
2459            out,
2460            "lumen_node_prev",
2461            lumen_script::node_query::node_prev,
2462        )
2463    })
2464}
2465
2466/// Children of `node` in document order, written to `*out_list` (own +
2467/// free with [`lumen_nodelist_free`]). Thread-safe.
2468#[unsafe(no_mangle)]
2469pub unsafe extern "C" fn lumen_node_children(
2470    node: LumenNode,
2471    out_list: *mut LumenNodeList,
2472) -> LumenStatus {
2473    catch(|| {
2474        if out_list.is_null() {
2475            set_last_error("lumen_node_children: null out_list");
2476            return LumenStatus::ErrBadArg;
2477        }
2478        unsafe { *out_list = node_list_from(lumen_script::node_query::node_children(node)) };
2479        LumenStatus::Ok
2480    })
2481}
2482
2483/// Nearest ancestor-or-self of `node` matching `selector`, written to
2484/// `*out` (`0` when none). Bad selector returns [`LumenStatus::ErrCss`].
2485/// Thread-safe.
2486#[unsafe(no_mangle)]
2487pub unsafe extern "C" fn lumen_node_closest(
2488    node: LumenNode,
2489    selector: *const c_char,
2490    out: *mut LumenNode,
2491) -> LumenStatus {
2492    catch(|| {
2493        if out.is_null() {
2494            set_last_error("lumen_node_closest: null out");
2495            return LumenStatus::ErrBadArg;
2496        }
2497        unsafe { *out = 0 };
2498        let sel = match ffi_selector(selector, "lumen_node_closest") {
2499            Ok(s) => s,
2500            Err(status) => return status,
2501        };
2502        match lumen_script::node_query::node_closest(node, &sel) {
2503            Ok(hit) => {
2504                unsafe { *out = hit.unwrap_or(0) };
2505                LumenStatus::Ok
2506            }
2507            Err(e) => {
2508                set_last_error(format!("lumen_node_closest: {e}"));
2509                LumenStatus::ErrCss
2510            }
2511        }
2512    })
2513}
2514
2515/// Whether `node` is present in the current snapshot (`1`) or not (`0`),
2516/// written to `*out`. The snapshot rebuilds each tick, so a despawned node
2517/// reads `0`. Thread-safe.
2518#[unsafe(no_mangle)]
2519pub unsafe extern "C" fn lumen_node_valid(node: LumenNode, out: *mut c_int) -> LumenStatus {
2520    catch(|| {
2521        if out.is_null() {
2522            set_last_error("lumen_node_valid: null out");
2523            return LumenStatus::ErrBadArg;
2524        }
2525        unsafe { *out = lumen_script::node_query::node_valid(node) as c_int };
2526        LumenStatus::Ok
2527    })
2528}
2529
2530/// Read the handle at `index` in `list`, written to `*out`. Out-of-range
2531/// (or null list) returns [`LumenStatus::ErrBadArg`]. The iteration
2532/// primitive: walk `0..list.len`. Thread-safe.
2533///
2534/// # Safety
2535/// `list` must be a list returned by a query / children call and not yet
2536/// freed.
2537#[unsafe(no_mangle)]
2538pub unsafe extern "C" fn lumen_nodelist_get(
2539    list: LumenNodeList,
2540    index: usize,
2541    out: *mut LumenNode,
2542) -> LumenStatus {
2543    catch(|| {
2544        if out.is_null() {
2545            set_last_error("lumen_nodelist_get: null out");
2546            return LumenStatus::ErrBadArg;
2547        }
2548        if list.ptr.is_null() || index >= list.len {
2549            set_last_error("lumen_nodelist_get: index out of range");
2550            return LumenStatus::ErrBadArg;
2551        }
2552        unsafe { *out = *list.ptr.add(index) };
2553        LumenStatus::Ok
2554    })
2555}
2556
2557/// Release a [`LumenNodeList`] returned by a query / children call.
2558/// Double-free / freeing a non-Lumen list is undefined; call once.
2559///
2560/// # Safety
2561/// `list` must come from a Lumen query / children call and not have been
2562/// freed already.
2563#[unsafe(no_mangle)]
2564pub unsafe extern "C" fn lumen_nodelist_free(list: LumenNodeList) {
2565    if list.ptr.is_null() || list.len == 0 {
2566        return;
2567    }
2568    unsafe {
2569        drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(
2570            list.ptr, list.len,
2571        )));
2572    }
2573}
2574
2575// ============================================================
2576// Low-level introspection (phase 5), over the C-ABI. Read-only over the
2577// per-tick snapshot; no `LumenApp` handle. `computed_style` / `attrs` /
2578// `component` / `signals_all` return owned key-value buffers freed with
2579// `lumen_kvlist_free`; `classes` / `components` return an owned string
2580// buffer freed with `lumen_strlist_free`; `dump_tree` / `outer_markup`
2581// return an owned C string freed with `lumen_string_free`.
2582// ============================================================
2583
2584/// Post-layout box (design 4.7 `rect()` / `content_rect()`). Local `x` / `y`
2585/// are relative to the parent; `client_*` are window coordinates.
2586#[repr(C)]
2587pub struct LumenRect {
2588    /// Local x.
2589    pub x: f64,
2590    /// Local y.
2591    pub y: f64,
2592    /// Width.
2593    pub width: f64,
2594    /// Height.
2595    pub height: f64,
2596    /// Window-space x.
2597    pub client_x: f64,
2598    /// Window-space y.
2599    pub client_y: f64,
2600}
2601
2602/// Scroll offsets + travel limits (`scroll()`).
2603#[repr(C)]
2604pub struct LumenScroll {
2605    /// Horizontal offset.
2606    pub x: f64,
2607    /// Vertical offset.
2608    pub y: f64,
2609    /// Max horizontal offset.
2610    pub max_x: f64,
2611    /// Max vertical offset.
2612    pub max_y: f64,
2613}
2614
2615/// Pointer state snapshot (`pointer_state()`).
2616#[repr(C)]
2617pub struct LumenPointerState {
2618    /// Window-space x.
2619    pub x: f64,
2620    /// Window-space y.
2621    pub y: f64,
2622    /// Non-zero while the pointer is inside the window.
2623    pub inside: c_int,
2624    /// Bit 0 set while the primary button is held.
2625    pub buttons: u32,
2626    /// Shift held.
2627    pub shift: c_int,
2628    /// Control held.
2629    pub ctrl: c_int,
2630    /// Alt held.
2631    pub alt: c_int,
2632    /// Super / Command held.
2633    pub super_: c_int,
2634}
2635
2636/// Per-frame counters (`frame_info()`).
2637#[repr(C)]
2638pub struct LumenFrameInfo {
2639    /// Monotonic tick counter.
2640    pub frame: u64,
2641    /// Milliseconds since the previous frame.
2642    pub dt_ms: f64,
2643    /// Layout-dirty element count.
2644    pub dirty_count: u64,
2645}
2646
2647/// One `(key, value)` pair in a [`LumenKVList`]. Both are owned, UTF-8,
2648/// NUL-terminated strings freed by [`lumen_kvlist_free`].
2649#[repr(C)]
2650pub struct LumenKV {
2651    /// Owned key.
2652    pub key: *mut c_char,
2653    /// Owned value.
2654    pub value: *mut c_char,
2655}
2656
2657/// Owned key-value buffer returned by the string-map introspection reads.
2658/// Free with [`lumen_kvlist_free`].
2659#[repr(C)]
2660pub struct LumenKVList {
2661    /// Heap pointer to `len` pairs, or null when `len == 0`.
2662    pub ptr: *mut LumenKV,
2663    /// Number of pairs.
2664    pub len: usize,
2665}
2666
2667/// Owned string buffer returned by `classes` / `components`. Free with
2668/// [`lumen_strlist_free`].
2669#[repr(C)]
2670pub struct LumenStrList {
2671    /// Heap pointer to `len` owned C strings, or null when `len == 0`.
2672    pub ptr: *mut *mut c_char,
2673    /// Number of strings.
2674    pub len: usize,
2675}
2676
2677fn owned_cstring(s: &str) -> *mut c_char {
2678    CString::new(s).unwrap_or_default().into_raw()
2679}
2680
2681fn kvlist_from(pairs: Vec<(String, String)>) -> LumenKVList {
2682    if pairs.is_empty() {
2683        return LumenKVList {
2684            ptr: ptr::null_mut(),
2685            len: 0,
2686        };
2687    }
2688    let mut boxed: Box<[LumenKV]> = pairs
2689        .into_iter()
2690        .map(|(k, v)| LumenKV {
2691            key: owned_cstring(&k),
2692            value: owned_cstring(&v),
2693        })
2694        .collect();
2695    let list = LumenKVList {
2696        ptr: boxed.as_mut_ptr(),
2697        len: boxed.len(),
2698    };
2699    std::mem::forget(boxed);
2700    list
2701}
2702
2703fn strlist_from(items: Vec<String>) -> LumenStrList {
2704    if items.is_empty() {
2705        return LumenStrList {
2706            ptr: ptr::null_mut(),
2707            len: 0,
2708        };
2709    }
2710    let mut boxed: Box<[*mut c_char]> = items.iter().map(|s| owned_cstring(s)).collect();
2711    let list = LumenStrList {
2712        ptr: boxed.as_mut_ptr(),
2713        len: boxed.len(),
2714    };
2715    std::mem::forget(boxed);
2716    list
2717}
2718
2719fn rect_to_ffi(r: lumen_script::introspect::NodeRect) -> LumenRect {
2720    LumenRect {
2721        x: r.x as f64,
2722        y: r.y as f64,
2723        width: r.width as f64,
2724        height: r.height as f64,
2725        client_x: r.client_x as f64,
2726        client_y: r.client_y as f64,
2727    }
2728}
2729
2730/// Post-layout border-box of `node`, written to `*out`. Thread-safe.
2731#[unsafe(no_mangle)]
2732pub unsafe extern "C" fn lumen_node_rect(node: LumenNode, out: *mut LumenRect) -> LumenStatus {
2733    catch(|| {
2734        if out.is_null() {
2735            set_last_error("lumen_node_rect: null out");
2736            return LumenStatus::ErrBadArg;
2737        }
2738        match lumen_script::introspect::node_rect(node) {
2739            Some(r) => {
2740                unsafe { *out = rect_to_ffi(r) };
2741                LumenStatus::Ok
2742            }
2743            None => LumenStatus::ErrInvalidHandle,
2744        }
2745    })
2746}
2747
2748/// Content-box (inner box minus padding + border) of `node`. Thread-safe.
2749#[unsafe(no_mangle)]
2750pub unsafe extern "C" fn lumen_node_content_rect(
2751    node: LumenNode,
2752    out: *mut LumenRect,
2753) -> LumenStatus {
2754    catch(|| {
2755        if out.is_null() {
2756            set_last_error("lumen_node_content_rect: null out");
2757            return LumenStatus::ErrBadArg;
2758        }
2759        match lumen_script::introspect::node_content_rect(node) {
2760            Some(r) => {
2761                unsafe { *out = rect_to_ffi(r) };
2762                LumenStatus::Ok
2763            }
2764            None => LumenStatus::ErrInvalidHandle,
2765        }
2766    })
2767}
2768
2769/// Scroll offsets + limits of `node`, written to `*out`. Thread-safe.
2770#[unsafe(no_mangle)]
2771pub unsafe extern "C" fn lumen_node_scroll(node: LumenNode, out: *mut LumenScroll) -> LumenStatus {
2772    catch(|| {
2773        if out.is_null() {
2774            set_last_error("lumen_node_scroll: null out");
2775            return LumenStatus::ErrBadArg;
2776        }
2777        match lumen_script::introspect::node_scroll(node) {
2778            Some(s) => {
2779                unsafe {
2780                    *out = LumenScroll {
2781                        x: s.x as f64,
2782                        y: s.y as f64,
2783                        max_x: s.max_x as f64,
2784                        max_y: s.max_y as f64,
2785                    }
2786                };
2787                LumenStatus::Ok
2788            }
2789            None => LumenStatus::ErrInvalidHandle,
2790        }
2791    })
2792}
2793
2794/// Effective visibility of `node` (`1` / `0`), written to `*out`.
2795#[unsafe(no_mangle)]
2796pub unsafe extern "C" fn lumen_node_is_visible(node: LumenNode, out: *mut c_int) -> LumenStatus {
2797    catch(|| {
2798        if out.is_null() {
2799            set_last_error("lumen_node_is_visible: null out");
2800            return LumenStatus::ErrBadArg;
2801        }
2802        unsafe { *out = c_int::from(lumen_script::introspect::node_is_visible(node)) };
2803        LumenStatus::Ok
2804    })
2805}
2806
2807/// Resolved stacking order of `node`, written to `*out`.
2808#[unsafe(no_mangle)]
2809pub unsafe extern "C" fn lumen_node_z_index(node: LumenNode, out: *mut c_int) -> LumenStatus {
2810    catch(|| {
2811        if out.is_null() {
2812            set_last_error("lumen_node_z_index: null out");
2813            return LumenStatus::ErrBadArg;
2814        }
2815        unsafe { *out = lumen_script::introspect::node_z_index(node) as c_int };
2816        LumenStatus::Ok
2817    })
2818}
2819
2820/// Raw `(index, generation)` of `node`, written to `*out_index` / `*out_gen`.
2821#[unsafe(no_mangle)]
2822pub unsafe extern "C" fn lumen_node_entity_id(
2823    node: LumenNode,
2824    out_index: *mut u32,
2825    out_gen: *mut u32,
2826) -> LumenStatus {
2827    catch(|| {
2828        if out_index.is_null() || out_gen.is_null() {
2829            set_last_error("lumen_node_entity_id: null out");
2830            return LumenStatus::ErrBadArg;
2831        }
2832        match lumen_script::introspect::node_entity_id(node) {
2833            Some((index, generation)) => {
2834                unsafe {
2835                    *out_index = index;
2836                    *out_gen = generation;
2837                }
2838                LumenStatus::Ok
2839            }
2840            None => LumenStatus::ErrInvalidHandle,
2841        }
2842    })
2843}
2844
2845/// Full computed style of `node` as an owned key-value buffer. Free with
2846/// [`lumen_kvlist_free`]. An inspection call. Thread-safe.
2847#[unsafe(no_mangle)]
2848pub unsafe extern "C" fn lumen_node_computed_style(
2849    node: LumenNode,
2850    out: *mut LumenKVList,
2851) -> LumenStatus {
2852    catch(|| {
2853        if out.is_null() {
2854            set_last_error("lumen_node_computed_style: null out");
2855            return LumenStatus::ErrBadArg;
2856        }
2857        unsafe { *out = kvlist_from(lumen_script::introspect::node_computed_style_map(node)) };
2858        LumenStatus::Ok
2859    })
2860}
2861
2862/// Full attribute map of `node`. Free with [`lumen_kvlist_free`].
2863#[unsafe(no_mangle)]
2864pub unsafe extern "C" fn lumen_node_attrs(node: LumenNode, out: *mut LumenKVList) -> LumenStatus {
2865    catch(|| {
2866        if out.is_null() {
2867            set_last_error("lumen_node_attrs: null out");
2868            return LumenStatus::ErrBadArg;
2869        }
2870        unsafe { *out = kvlist_from(lumen_script::introspect::node_attrs(node)) };
2871        LumenStatus::Ok
2872    })
2873}
2874
2875/// Inline-style override map of `node`. Free with [`lumen_kvlist_free`].
2876#[unsafe(no_mangle)]
2877pub unsafe extern "C" fn lumen_node_inline_style(
2878    node: LumenNode,
2879    out: *mut LumenKVList,
2880) -> LumenStatus {
2881    catch(|| {
2882        if out.is_null() {
2883            set_last_error("lumen_node_inline_style: null out");
2884            return LumenStatus::ErrBadArg;
2885        }
2886        unsafe { *out = kvlist_from(lumen_script::introspect::node_inline_style(node)) };
2887        LumenStatus::Ok
2888    })
2889}
2890
2891/// Field map of `node`'s `name` component. Free with [`lumen_kvlist_free`].
2892/// A non-whitelisted component name returns [`LumenStatus::ErrBadArg`].
2893#[unsafe(no_mangle)]
2894pub unsafe extern "C" fn lumen_node_component(
2895    node: LumenNode,
2896    name: *const c_char,
2897    out: *mut LumenKVList,
2898) -> LumenStatus {
2899    catch(|| {
2900        if out.is_null() {
2901            set_last_error("lumen_node_component: null out");
2902            return LumenStatus::ErrBadArg;
2903        }
2904        let name = match ffi_selector(name, "lumen_node_component") {
2905            Ok(s) => s,
2906            Err(status) => return status,
2907        };
2908        match lumen_script::introspect::node_component(node, &name) {
2909            Ok(map) => {
2910                unsafe { *out = kvlist_from(map.unwrap_or_default()) };
2911                LumenStatus::Ok
2912            }
2913            Err(e) => {
2914                set_last_error(format!("lumen_node_component: {e}"));
2915                LumenStatus::ErrBadArg
2916            }
2917        }
2918    })
2919}
2920
2921/// Class list of `node`. Free with [`lumen_strlist_free`].
2922#[unsafe(no_mangle)]
2923pub unsafe extern "C" fn lumen_node_classes(
2924    node: LumenNode,
2925    out: *mut LumenStrList,
2926) -> LumenStatus {
2927    catch(|| {
2928        if out.is_null() {
2929            set_last_error("lumen_node_classes: null out");
2930            return LumenStatus::ErrBadArg;
2931        }
2932        unsafe { *out = strlist_from(lumen_script::introspect::node_classes(node)) };
2933        LumenStatus::Ok
2934    })
2935}
2936
2937/// Names of the whitelisted components present on `node`. Free with
2938/// [`lumen_strlist_free`].
2939#[unsafe(no_mangle)]
2940pub unsafe extern "C" fn lumen_node_components(
2941    node: LumenNode,
2942    out: *mut LumenStrList,
2943) -> LumenStatus {
2944    catch(|| {
2945        if out.is_null() {
2946            set_last_error("lumen_node_components: null out");
2947            return LumenStatus::ErrBadArg;
2948        }
2949        unsafe { *out = strlist_from(lumen_script::introspect::node_components(node)) };
2950        LumenStatus::Ok
2951    })
2952}
2953
2954/// Serialize `node`'s subtree to `.lmn`-ish text. Owned C string, free with
2955/// [`lumen_string_free`].
2956#[unsafe(no_mangle)]
2957pub unsafe extern "C" fn lumen_node_outer_markup(
2958    node: LumenNode,
2959    out: *mut *mut c_char,
2960) -> LumenStatus {
2961    catch(|| {
2962        if out.is_null() {
2963            set_last_error("lumen_node_outer_markup: null out");
2964            return LumenStatus::ErrBadArg;
2965        }
2966        unsafe { *out = owned_cstring(&lumen_script::introspect::outer_markup(node)) };
2967        LumenStatus::Ok
2968    })
2969}
2970
2971/// Serialize `node`'s children (not the node itself) to `.lmn`-ish text --
2972/// the read half of [`lumen_node_set_inner_markup`]. Owned C string, free
2973/// with [`lumen_string_free`].
2974#[unsafe(no_mangle)]
2975pub unsafe extern "C" fn lumen_node_inner_markup(
2976    node: LumenNode,
2977    out: *mut *mut c_char,
2978) -> LumenStatus {
2979    catch(|| {
2980        if out.is_null() {
2981            set_last_error("lumen_node_inner_markup: null out");
2982            return LumenStatus::ErrBadArg;
2983        }
2984        unsafe { *out = owned_cstring(&lumen_script::introspect::inner_markup(node)) };
2985        LumenStatus::Ok
2986    })
2987}
2988
2989/// Whole-tree structural dump. Owned C string, free with
2990/// [`lumen_string_free`]. An inspection call.
2991#[unsafe(no_mangle)]
2992pub unsafe extern "C" fn lumen_dump_tree(out: *mut *mut c_char) -> LumenStatus {
2993    catch(|| {
2994        if out.is_null() {
2995            set_last_error("lumen_dump_tree: null out");
2996            return LumenStatus::ErrBadArg;
2997        }
2998        unsafe { *out = owned_cstring(&lumen_script::introspect::dump_tree()) };
2999        LumenStatus::Ok
3000    })
3001}
3002
3003/// Current pointer state, written to `*out`. Thread-safe.
3004#[unsafe(no_mangle)]
3005pub unsafe extern "C" fn lumen_pointer_state(out: *mut LumenPointerState) -> LumenStatus {
3006    catch(|| {
3007        if out.is_null() {
3008            set_last_error("lumen_pointer_state: null out");
3009            return LumenStatus::ErrBadArg;
3010        }
3011        let p = lumen_script::introspect::pointer_state();
3012        unsafe {
3013            *out = LumenPointerState {
3014                x: p.x as f64,
3015                y: p.y as f64,
3016                inside: c_int::from(p.inside),
3017                buttons: p.buttons,
3018                shift: c_int::from(p.shift),
3019                ctrl: c_int::from(p.ctrl),
3020                alt: c_int::from(p.alt),
3021                super_: c_int::from(p.super_),
3022            }
3023        };
3024        LumenStatus::Ok
3025    })
3026}
3027
3028/// Current frame counters, written to `*out`. Thread-safe.
3029#[unsafe(no_mangle)]
3030pub unsafe extern "C" fn lumen_frame_info(out: *mut LumenFrameInfo) -> LumenStatus {
3031    catch(|| {
3032        if out.is_null() {
3033            set_last_error("lumen_frame_info: null out");
3034            return LumenStatus::ErrBadArg;
3035        }
3036        let f = lumen_script::introspect::frame_info();
3037        unsafe {
3038            *out = LumenFrameInfo {
3039                frame: f.frame,
3040                dt_ms: f.dt_ms,
3041                dirty_count: f.dirty_count,
3042            }
3043        };
3044        LumenStatus::Ok
3045    })
3046}
3047
3048/// The whole signal set as an owned key-value buffer. Free with
3049/// [`lumen_kvlist_free`]. An inspection call. Thread-safe.
3050#[unsafe(no_mangle)]
3051pub unsafe extern "C" fn lumen_signals_all(out: *mut LumenKVList) -> LumenStatus {
3052    catch(|| {
3053        if out.is_null() {
3054            set_last_error("lumen_signals_all: null out");
3055            return LumenStatus::ErrBadArg;
3056        }
3057        unsafe { *out = kvlist_from(lumen_script::introspect::signals_all()) };
3058        LumenStatus::Ok
3059    })
3060}
3061
3062/// Release a [`LumenKVList`] returned by an introspection read.
3063#[unsafe(no_mangle)]
3064pub unsafe extern "C" fn lumen_kvlist_free(list: LumenKVList) {
3065    if list.ptr.is_null() || list.len == 0 {
3066        return;
3067    }
3068    unsafe {
3069        let slice = Box::from_raw(std::ptr::slice_from_raw_parts_mut(list.ptr, list.len));
3070        for kv in slice.iter() {
3071            if !kv.key.is_null() {
3072                drop(CString::from_raw(kv.key));
3073            }
3074            if !kv.value.is_null() {
3075                drop(CString::from_raw(kv.value));
3076            }
3077        }
3078    }
3079}
3080
3081/// Release a [`LumenStrList`] returned by `classes` / `components`.
3082#[unsafe(no_mangle)]
3083pub unsafe extern "C" fn lumen_strlist_free(list: LumenStrList) {
3084    if list.ptr.is_null() || list.len == 0 {
3085        return;
3086    }
3087    unsafe {
3088        let slice = Box::from_raw(std::ptr::slice_from_raw_parts_mut(list.ptr, list.len));
3089        for s in slice.iter() {
3090            if !s.is_null() {
3091                drop(CString::from_raw(*s));
3092            }
3093        }
3094    }
3095}
3096
3097/// Release an owned C string returned by `dump_tree` / `outer_markup`.
3098#[unsafe(no_mangle)]
3099pub unsafe extern "C" fn lumen_string_free(s: *mut c_char) {
3100    if s.is_null() {
3101        return;
3102    }
3103    unsafe { drop(CString::from_raw(s)) };
3104}
3105
3106// ============================================================
3107// Dynamic DOM mutation (phases 2 + 3) + `window` / `document` /
3108// `history` (section 4.8), over the C-ABI.
3109//
3110// Mutations are fire-and-forget: each pushes a command onto the
3111// process-global external DOM bus, which the runtime drains into the same
3112// applier as script-issued mutations, so a `spawn` + chained edits from a C
3113// caller materialize together in one tick. Every mutator returns
3114// `LumenStatus` (no panic crosses the ABI); a fluent SDK wrapper returns the
3115// same `LumenNode` it passed in. `spawn` / `clone` write the new handle to
3116// an out-param.
3117// ============================================================
3118
3119fn push_dom(cmd: lumen_script::ScriptCommand) -> LumenStatus {
3120    lumen_script::node_query::push_external_dom_command(cmd);
3121    LumenStatus::Ok
3122}
3123
3124/// Set an attribute on `node`. KNOWN attrs (`id` / `class` / `text` /
3125/// `disabled`) route to their typed component; others land in the generic
3126/// attribute map. Thread-safe.
3127#[unsafe(no_mangle)]
3128pub unsafe extern "C" fn lumen_node_set_attr(
3129    node: LumenNode,
3130    name: *const c_char,
3131    value: *const c_char,
3132) -> LumenStatus {
3133    catch(|| {
3134        let name = match ffi_selector(name, "lumen_node_set_attr") {
3135            Ok(s) => s,
3136            Err(status) => return status,
3137        };
3138        let value = match ffi_selector(value, "lumen_node_set_attr") {
3139            Ok(s) => s,
3140            Err(status) => return status,
3141        };
3142        push_dom(lumen_script::ScriptCommand::SetAttr { node, name, value })
3143    })
3144}
3145
3146/// Remove an attribute from `node`. Thread-safe.
3147#[unsafe(no_mangle)]
3148pub unsafe extern "C" fn lumen_node_remove_attr(
3149    node: LumenNode,
3150    name: *const c_char,
3151) -> LumenStatus {
3152    catch(|| {
3153        let name = match ffi_selector(name, "lumen_node_remove_attr") {
3154            Ok(s) => s,
3155            Err(status) => return status,
3156        };
3157        push_dom(lumen_script::ScriptCommand::RemoveAttr { node, name })
3158    })
3159}
3160
3161/// Replace `node`'s text content. Thread-safe.
3162#[unsafe(no_mangle)]
3163pub unsafe extern "C" fn lumen_node_set_text(node: LumenNode, text: *const c_char) -> LumenStatus {
3164    catch(|| {
3165        let text = match ffi_selector(text, "lumen_node_set_text") {
3166            Ok(s) => s,
3167            Err(status) => return status,
3168        };
3169        push_dom(lumen_script::ScriptCommand::SetNodeText { node, text })
3170    })
3171}
3172
3173/// Replace `node`'s children with the subtree parsed from `markup`
3174/// (`element.innerHTML = ...`). Parsed by the injected front-end and spawned
3175/// through the same path the `<for>` reconciler uses; a no-op on the
3176/// precompiled-artifact path (no parser linked). Guarded: do not feed
3177/// untrusted content; this injects live markup. Thread-safe.
3178#[unsafe(no_mangle)]
3179pub unsafe extern "C" fn lumen_node_set_inner_markup(
3180    node: LumenNode,
3181    markup: *const c_char,
3182) -> LumenStatus {
3183    catch(|| {
3184        let markup = match ffi_selector(markup, "lumen_node_set_inner_markup") {
3185            Ok(s) => s,
3186            Err(status) => return status,
3187        };
3188        push_dom(lumen_script::ScriptCommand::SetInnerMarkup { node, markup })
3189    })
3190}
3191
3192/// Add one class to `node`'s class list. Thread-safe.
3193#[unsafe(no_mangle)]
3194pub unsafe extern "C" fn lumen_node_class_add(
3195    node: LumenNode,
3196    class: *const c_char,
3197) -> LumenStatus {
3198    catch(|| {
3199        let class = match ffi_selector(class, "lumen_node_class_add") {
3200            Ok(s) => s,
3201            Err(status) => return status,
3202        };
3203        push_dom(lumen_script::ScriptCommand::ClassAdd { node, class })
3204    })
3205}
3206
3207/// Remove one class from `node`'s class list. Thread-safe.
3208#[unsafe(no_mangle)]
3209pub unsafe extern "C" fn lumen_node_class_remove(
3210    node: LumenNode,
3211    class: *const c_char,
3212) -> LumenStatus {
3213    catch(|| {
3214        let class = match ffi_selector(class, "lumen_node_class_remove") {
3215            Ok(s) => s,
3216            Err(status) => return status,
3217        };
3218        push_dom(lumen_script::ScriptCommand::ClassRemove { node, class })
3219    })
3220}
3221
3222/// Toggle one class on `node`'s class list. Thread-safe.
3223#[unsafe(no_mangle)]
3224pub unsafe extern "C" fn lumen_node_class_toggle(
3225    node: LumenNode,
3226    class: *const c_char,
3227) -> LumenStatus {
3228    catch(|| {
3229        let class = match ffi_selector(class, "lumen_node_class_toggle") {
3230            Ok(s) => s,
3231            Err(status) => return status,
3232        };
3233        push_dom(lumen_script::ScriptCommand::ClassToggle { node, class })
3234    })
3235}
3236
3237/// Set an inline style property on `node` (`element.style`). Thread-safe.
3238#[unsafe(no_mangle)]
3239pub unsafe extern "C" fn lumen_node_set_style(
3240    node: LumenNode,
3241    name: *const c_char,
3242    value: *const c_char,
3243) -> LumenStatus {
3244    catch(|| {
3245        let name = match ffi_selector(name, "lumen_node_set_style") {
3246            Ok(s) => s,
3247            Err(status) => return status,
3248        };
3249        let value = match ffi_selector(value, "lumen_node_set_style") {
3250            Ok(s) => s,
3251            Err(status) => return status,
3252        };
3253        push_dom(lumen_script::ScriptCommand::SetStyleProp { node, name, value })
3254    })
3255}
3256
3257/// Remove an inline style property from `node`. Thread-safe.
3258#[unsafe(no_mangle)]
3259pub unsafe extern "C" fn lumen_node_remove_style(
3260    node: LumenNode,
3261    name: *const c_char,
3262) -> LumenStatus {
3263    catch(|| {
3264        let name = match ffi_selector(name, "lumen_node_remove_style") {
3265            Ok(s) => s,
3266            Err(status) => return status,
3267        };
3268        push_dom(lumen_script::ScriptCommand::RemoveStyleProp { node, name })
3269    })
3270}
3271
3272/// Create a fresh detached element with markup tag `tag`, writing its handle
3273/// to `*out`. The handle is valid for the rest of the tick; attach it with
3274/// [`lumen_node_append`] / [`lumen_node_set_parent`]. Thread-safe.
3275#[unsafe(no_mangle)]
3276pub unsafe extern "C" fn lumen_node_spawn(tag: *const c_char, out: *mut LumenNode) -> LumenStatus {
3277    catch(|| {
3278        if out.is_null() {
3279            set_last_error("lumen_node_spawn: null out");
3280            return LumenStatus::ErrBadArg;
3281        }
3282        let tag = match ffi_selector(tag, "lumen_node_spawn") {
3283            Ok(s) => s,
3284            Err(status) => return status,
3285        };
3286        let (handle, cmd) = lumen_script::node_query::build_spawn(&tag);
3287        unsafe { *out = handle };
3288        push_dom(cmd)
3289    })
3290}
3291
3292/// Deep-clone `source`'s subtree into a fresh detached node, writing its
3293/// handle to `*out`. Thread-safe.
3294#[unsafe(no_mangle)]
3295pub unsafe extern "C" fn lumen_node_clone(source: LumenNode, out: *mut LumenNode) -> LumenStatus {
3296    catch(|| {
3297        if out.is_null() {
3298            set_last_error("lumen_node_clone: null out");
3299            return LumenStatus::ErrBadArg;
3300        }
3301        let (handle, cmd) = lumen_script::node_query::build_clone(source);
3302        unsafe { *out = handle };
3303        push_dom(cmd)
3304    })
3305}
3306
3307/// Append `child` under `parent` (`appendChild`). Thread-safe.
3308#[unsafe(no_mangle)]
3309pub unsafe extern "C" fn lumen_node_append(parent: LumenNode, child: LumenNode) -> LumenStatus {
3310    catch(|| {
3311        push_dom(lumen_script::ScriptCommand::Insert {
3312            parent,
3313            node: child,
3314            before: 0,
3315        })
3316    })
3317}
3318
3319/// Insert `child` under `parent` before `reference` (`insertBefore`).
3320/// A `reference` of `0` appends. Thread-safe.
3321#[unsafe(no_mangle)]
3322pub unsafe extern "C" fn lumen_node_insert_before(
3323    parent: LumenNode,
3324    child: LumenNode,
3325    reference: LumenNode,
3326) -> LumenStatus {
3327    catch(|| {
3328        push_dom(lumen_script::ScriptCommand::Insert {
3329            parent,
3330            node: child,
3331            before: reference,
3332        })
3333    })
3334}
3335
3336/// Attach `node` under `parent` (`node.set_parent` / reparent). Thread-safe.
3337#[unsafe(no_mangle)]
3338pub unsafe extern "C" fn lumen_node_set_parent(node: LumenNode, parent: LumenNode) -> LumenStatus {
3339    catch(|| {
3340        push_dom(lumen_script::ScriptCommand::Insert {
3341            parent,
3342            node,
3343            before: 0,
3344        })
3345    })
3346}
3347
3348/// Replace `old` with `new` in `old`'s parent, despawning `old`'s subtree.
3349/// Thread-safe.
3350#[unsafe(no_mangle)]
3351pub unsafe extern "C" fn lumen_node_replace_with(old: LumenNode, new: LumenNode) -> LumenStatus {
3352    catch(|| push_dom(lumen_script::ScriptCommand::ReplaceWith { old, new }))
3353}
3354
3355/// Detach and despawn `node` and its subtree (`node.remove`). Thread-safe.
3356#[unsafe(no_mangle)]
3357pub unsafe extern "C" fn lumen_node_remove(node: LumenNode) -> LumenStatus {
3358    catch(|| push_dom(lumen_script::ScriptCommand::RemoveNode { node }))
3359}
3360
3361/// `window.set_href`: navigate to a page path. Binds onto the same
3362/// [`lumen_core::nav`] bus as [`lumen_navigate`]. Thread-safe.
3363#[unsafe(no_mangle)]
3364pub unsafe extern "C" fn lumen_window_set_href(path: *const c_char) -> LumenStatus {
3365    catch(|| {
3366        let path = match ffi_selector(path, "lumen_window_set_href") {
3367            Ok(s) => s,
3368            Err(status) => return status,
3369        };
3370        lumen_core::nav::navigate(path);
3371        LumenStatus::Ok
3372    })
3373}
3374
3375/// `window.reload`: re-navigate to the current page. Thread-safe.
3376#[unsafe(no_mangle)]
3377pub unsafe extern "C" fn lumen_window_reload() -> LumenStatus {
3378    catch(|| {
3379        lumen_core::nav::navigate(lumen_core::nav::current());
3380        LumenStatus::Ok
3381    })
3382}
3383
3384/// `window.set_title`. Thread-safe.
3385#[unsafe(no_mangle)]
3386pub unsafe extern "C" fn lumen_window_set_title(title: *const c_char) -> LumenStatus {
3387    catch(|| {
3388        let title = match ffi_selector(title, "lumen_window_set_title") {
3389            Ok(s) => s,
3390            Err(status) => return status,
3391        };
3392        push_dom(lumen_script::ScriptCommand::WindowSetTitle { title })
3393    })
3394}
3395
3396/// `window.set_size` in logical pixels. Thread-safe.
3397#[unsafe(no_mangle)]
3398pub unsafe extern "C" fn lumen_window_set_size(width: f32, height: f32) -> LumenStatus {
3399    catch(|| push_dom(lumen_script::ScriptCommand::WindowSetSize { width, height }))
3400}
3401
3402/// `window.dpr`: current device-pixel ratio, written to `*out`.
3403/// Thread-safe.
3404#[unsafe(no_mangle)]
3405pub unsafe extern "C" fn lumen_window_dpr(out: *mut f32) -> LumenStatus {
3406    catch(|| {
3407        if out.is_null() {
3408            set_last_error("lumen_window_dpr: null out");
3409            return LumenStatus::ErrBadArg;
3410        }
3411        unsafe { *out = lumen_core::window_state::dpr() };
3412        LumenStatus::Ok
3413    })
3414}
3415
3416/// `history.go(delta)`: step `delta` entries (negative back, positive
3417/// forward) through the in-memory history stack. Thread-safe.
3418#[unsafe(no_mangle)]
3419pub unsafe extern "C" fn lumen_history_go(delta: c_int) -> LumenStatus {
3420    catch(|| {
3421        for _ in 0..delta.unsigned_abs() {
3422            if delta < 0 {
3423                lumen_core::nav::back();
3424            } else {
3425                lumen_core::nav::forward();
3426            }
3427        }
3428        LumenStatus::Ok
3429    })
3430}
3431
3432/// `document.spawn(tag)`: document-scoped create verb; writes the new
3433/// handle to `*out`. Thread-safe.
3434#[unsafe(no_mangle)]
3435pub unsafe extern "C" fn lumen_document_spawn(
3436    tag: *const c_char,
3437    out: *mut LumenNode,
3438) -> LumenStatus {
3439    catch(|| {
3440        if out.is_null() {
3441            set_last_error("lumen_document_spawn: null out");
3442            return LumenStatus::ErrBadArg;
3443        }
3444        let tag = match ffi_selector(tag, "lumen_document_spawn") {
3445            Ok(s) => s,
3446            Err(status) => return status,
3447        };
3448        let (handle, cmd) = lumen_script::node_query::build_spawn(&tag);
3449        unsafe { *out = handle };
3450        push_dom(cmd)
3451    })
3452}
3453
3454// ============================================================
3455// Dynamic DOM events (phase 4), over the C-ABI.
3456//
3457// Register a C callback + user data against a node and event type with
3458// `lumen_on`; unbind with `lumen_off`. During capture -> target -> bubble
3459// propagation the runtime invokes the callback, passing a `LumenEvent` with
3460// the scalar fields; the string fields (type / key / value) and the
3461// propagation controls are reached through the accessor functions, which
3462// read + mutate the current event. Unlike the mutators, `lumen_on` registers
3463// synchronously (the callback lives in a process-global binding registry the
3464// dispatcher shares) so no `LumenApp` handle or command drain is involved.
3465// ============================================================
3466
3467/// Off token returned by [`lumen_on`]; pass to [`lumen_off`] to unbind.
3468pub type LumenEventToken = u64;
3469
3470/// Scalar snapshot of the event delivered to a [`LumenEventFn`]. The string
3471/// fields (type / key / value) are read separately via [`lumen_event_type`] /
3472/// [`lumen_event_key`] / [`lumen_event_value`]. `#[repr(C)]` (never packed):
3473/// the fields are naturally aligned.
3474#[repr(C)]
3475pub struct LumenEvent {
3476    /// Target node (packed handle).
3477    pub target: LumenNode,
3478    /// Node whose handler is currently running (packed handle).
3479    pub current_target: LumenNode,
3480    /// Pointer x relative to the target, logical pixels.
3481    pub local_x: f64,
3482    /// Pointer y relative to the target, logical pixels.
3483    pub local_y: f64,
3484    /// Pointer x in window coordinates, logical pixels.
3485    pub client_x: f64,
3486    /// Pointer y in window coordinates, logical pixels.
3487    pub client_y: f64,
3488    /// Wheel delta x, logical pixels.
3489    pub delta_x: f64,
3490    /// Wheel delta y, logical pixels.
3491    pub delta_y: f64,
3492    /// Pointer button (`0` primary, `1` middle, `2` secondary, `-1` none).
3493    pub button: i64,
3494    /// Shift held (`0` / `1`).
3495    pub shift: u8,
3496    /// Control held (`0` / `1`).
3497    pub ctrl: u8,
3498    /// Alt held (`0` / `1`).
3499    pub alt: u8,
3500    /// Super / Cmd held (`0` / `1`).
3501    pub super_: u8,
3502}
3503
3504/// C callback invoked when a bound event fires. `event` is borrowed for the
3505/// duration of the call; copy anything retained. `user_data` is the pointer
3506/// passed to [`lumen_on`].
3507pub type LumenEventFn = unsafe extern "C" fn(event: *const LumenEvent, user_data: *mut c_void);
3508
3509/// Sendable capture of a C callback + its user data. The pointers are only
3510/// dereferenced on the main (dispatch) thread; the newtype asserts the
3511/// send/sync the binding registry requires.
3512struct CEventCallback {
3513    callback: LumenEventFn,
3514    user_data: *mut c_void,
3515}
3516
3517// SAFETY: the callback + user_data are only invoked from the runtime's
3518// single-threaded event dispatch. The embedder owns thread-safety of the
3519// user_data it hands over, matching every other C callback in this crate.
3520unsafe impl Send for CEventCallback {}
3521unsafe impl Sync for CEventCallback {}
3522
3523/// Build the scalar [`LumenEvent`] from the current-event cell.
3524fn current_lumen_event() -> LumenEvent {
3525    use lumen_script::event;
3526    let (lx, ly) = event::event_position_local();
3527    let (cx, cy) = event::event_position_client();
3528    let (dx, dy) = event::event_delta();
3529    let (shift, ctrl, alt, super_) = event::event_modifiers();
3530    LumenEvent {
3531        target: event::event_target(),
3532        current_target: event::event_current_target(),
3533        local_x: lx,
3534        local_y: ly,
3535        client_x: cx,
3536        client_y: cy,
3537        delta_x: dx,
3538        delta_y: dy,
3539        button: event::event_button(),
3540        shift: shift as u8,
3541        ctrl: ctrl as u8,
3542        alt: alt as u8,
3543        super_: super_ as u8,
3544    }
3545}
3546
3547/// Bind `callback` to `node` for `event_type`. `capture` (non-zero) makes it
3548/// a capture-phase listener. Returns an off token (`0` on a bad argument);
3549/// unbind with [`lumen_off`]. Thread-safe.
3550#[unsafe(no_mangle)]
3551pub unsafe extern "C" fn lumen_on(
3552    node: LumenNode,
3553    event_type: *const c_char,
3554    capture: c_int,
3555    callback: Option<LumenEventFn>,
3556    user_data: *mut c_void,
3557) -> LumenEventToken {
3558    catch_val(0u64, || {
3559        let Some(callback) = callback else {
3560            set_last_error("lumen_on: null callback");
3561            return 0;
3562        };
3563        let etype = match ffi_selector(event_type, "lumen_on") {
3564            Ok(s) => s,
3565            Err(_) => return 0,
3566        };
3567        let cb = CEventCallback {
3568            callback,
3569            user_data,
3570        };
3571        let invoke: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
3572            // Force whole-struct capture (edition-2021 closures otherwise
3573            // capture `cb.user_data` disjointly, defeating the Send/Sync
3574            // marker on `CEventCallback`).
3575            let cb = &cb;
3576            let ev = current_lumen_event();
3577            // SAFETY: invoked on the dispatch thread; `cb` outlives the call.
3578            unsafe { (cb.callback)(&ev, cb.user_data) };
3579        });
3580        lumen_script::event::register_native_binding(node, etype, capture != 0, invoke)
3581    })
3582}
3583
3584/// Unbind a callback previously registered with [`lumen_on`]. No-op for an
3585/// unknown token. Thread-safe.
3586#[unsafe(no_mangle)]
3587pub extern "C" fn lumen_off(token: LumenEventToken) -> LumenStatus {
3588    catch(|| {
3589        lumen_script::event::unregister_binding(token);
3590        LumenStatus::Ok
3591    })
3592}
3593
3594/// Copy the current event's type name into `buf` (string-out convention:
3595/// `*out_len` excludes the NUL; too-small returns
3596/// [`LumenStatus::ErrBufferTooSmall`] with the required capacity). Valid only
3597/// inside a [`LumenEventFn`] callback. Thread-safe.
3598#[unsafe(no_mangle)]
3599pub unsafe extern "C" fn lumen_event_type(
3600    buf: *mut c_char,
3601    buf_len: usize,
3602    out_len: *mut usize,
3603) -> LumenStatus {
3604    catch(|| write_string_out(&lumen_script::event::event_type(), buf, buf_len, out_len))
3605}
3606
3607/// Copy the current event's `key` (keyboard events) into `buf`. See
3608/// [`lumen_event_type`] for the convention. Thread-safe.
3609#[unsafe(no_mangle)]
3610pub unsafe extern "C" fn lumen_event_key(
3611    buf: *mut c_char,
3612    buf_len: usize,
3613    out_len: *mut usize,
3614) -> LumenStatus {
3615    catch(|| write_string_out(&lumen_script::event::event_key(), buf, buf_len, out_len))
3616}
3617
3618/// Copy the current event's `value` (input / change events) into `buf`. See
3619/// [`lumen_event_type`] for the convention. Thread-safe.
3620#[unsafe(no_mangle)]
3621pub unsafe extern "C" fn lumen_event_value(
3622    buf: *mut c_char,
3623    buf_len: usize,
3624    out_len: *mut usize,
3625) -> LumenStatus {
3626    catch(|| write_string_out(&lumen_script::event::event_value(), buf, buf_len, out_len))
3627}
3628
3629/// The current event's target node (packed handle), or `0` outside a
3630/// callback. Thread-safe.
3631#[unsafe(no_mangle)]
3632pub extern "C" fn lumen_event_target() -> LumenNode {
3633    catch_val(0u64, lumen_script::event::event_target)
3634}
3635
3636/// The current event's `current_target` node (packed handle). Thread-safe.
3637#[unsafe(no_mangle)]
3638pub extern "C" fn lumen_event_current_target() -> LumenNode {
3639    catch_val(0u64, lumen_script::event::event_current_target)
3640}
3641
3642/// Cancel the current event's default action (link navigation for `click`,
3643/// form submission for `submit`). Thread-safe.
3644#[unsafe(no_mangle)]
3645pub extern "C" fn lumen_event_prevent_default() -> LumenStatus {
3646    catch(|| {
3647        lumen_script::event::event_prevent_default();
3648        LumenStatus::Ok
3649    })
3650}
3651
3652/// Stop the current event propagating to further nodes. Thread-safe.
3653#[unsafe(no_mangle)]
3654pub extern "C" fn lumen_event_stop_propagation() -> LumenStatus {
3655    catch(|| {
3656        lumen_script::event::event_stop_propagation();
3657        LumenStatus::Ok
3658    })
3659}
3660
3661/// Stop the current event immediately: no further handlers run, on this node
3662/// or any other. Thread-safe.
3663#[unsafe(no_mangle)]
3664pub extern "C" fn lumen_event_stop_immediate_propagation() -> LumenStatus {
3665    catch(|| {
3666        lumen_script::event::event_stop_immediate_propagation();
3667        LumenStatus::Ok
3668    })
3669}
3670
3671#[cfg(test)]
3672mod tests {
3673    use super::*;
3674
3675    /// The typed-signal cache, the external property bus, the post-tick
3676    /// mirror, and the watcher registry are process-wide. Tests that touch
3677    /// them hold this lock: a drain run by one test midway through another
3678    /// moves values between the layers `typed_read` consults, and the
3679    /// other test reads a value it already overwrote.
3680    static SIGNAL_STATE: Mutex<()> = Mutex::new(());
3681
3682    #[test]
3683    fn abi_version_is_packed_correctly() {
3684        let v = lumen_abi_version();
3685        assert_eq!(v >> 16, LUMEN_ABI_MAJOR);
3686        assert_eq!((v >> 8) & 0xff, LUMEN_ABI_MINOR);
3687        assert_eq!(v & 0xff, LUMEN_ABI_PATCH);
3688    }
3689
3690    #[test]
3691    fn user_data_round_trips() {
3692        let mut x = 7u32;
3693        let raw = &mut x as *mut u32 as *mut c_void;
3694        let ud = UserData::from_raw(raw);
3695        assert_eq!(ud.as_ptr(), raw);
3696        let null = UserData::from_raw(ptr::null_mut());
3697        assert!(null.as_ptr().is_null());
3698    }
3699
3700    #[test]
3701    fn last_error_thread_local_then_global() {
3702        set_last_error("test message");
3703        let p = unsafe { lumen_last_error() };
3704        assert!(!p.is_null());
3705        let s = unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned();
3706        assert_eq!(s, "test message");
3707
3708        // From a fresh thread the TLS is empty - the global fallback kicks in.
3709        let handle = std::thread::spawn(|| {
3710            let p = unsafe { lumen_last_error() };
3711            assert!(
3712                !p.is_null(),
3713                "global fallback should surface the prior thread's error"
3714            );
3715            let s = unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned();
3716            assert_eq!(s, "test message");
3717        });
3718        handle.join().unwrap();
3719    }
3720
3721    #[test]
3722    fn classify_runtime_error_picks_specific_variant() {
3723        assert_eq!(
3724            classify_runtime_error("css parse failed"),
3725            LumenStatus::ErrCss
3726        );
3727        assert_eq!(
3728            classify_runtime_error("HTML XML error"),
3729            LumenStatus::ErrParse
3730        );
3731        assert_eq!(
3732            classify_runtime_error("asset PNG decode failed"),
3733            LumenStatus::ErrAsset
3734        );
3735        assert_eq!(
3736            classify_runtime_error("window surface create"),
3737            LumenStatus::ErrWindow
3738        );
3739        assert_eq!(
3740            classify_runtime_error("script error in on_click"),
3741            LumenStatus::ErrScript
3742        );
3743        assert_eq!(classify_runtime_error("file io"), LumenStatus::ErrIo);
3744        assert_eq!(
3745            classify_runtime_error("completely opaque thing"),
3746            LumenStatus::ErrRuntime
3747        );
3748    }
3749
3750    #[test]
3751    fn a_flat_app_directory_is_a_path_error_not_a_css_error() {
3752        // The migration hint names the `.css` files to move, so reading the
3753        // message would classify the app's layout as a stylesheet failure.
3754        let err = RunError::Layout(lumen_runtime::app_layout::FlatLayout {
3755            dir: std::path::PathBuf::from("/apps/demo"),
3756            files: vec!["main.css".to_owned(), "main.lmn".to_owned()],
3757        });
3758        assert_eq!(classify_run_error(&err), LumenStatus::ErrBadPath);
3759    }
3760
3761    #[test]
3762    fn typed_signal_int64_round_trips() {
3763        let _serial = SIGNAL_STATE.lock().unwrap_or_else(|e| e.into_inner());
3764        unsafe {
3765            let name = CString::new("typed_int_test").unwrap();
3766            assert_eq!(lumen_signal_set_int64(name.as_ptr(), 1234), LumenStatus::Ok);
3767            let mut out: i64 = 0;
3768            assert_eq!(
3769                lumen_signal_get_int64(name.as_ptr(), &mut out),
3770                LumenStatus::Ok
3771            );
3772            assert_eq!(out, 1234);
3773        }
3774    }
3775
3776    #[test]
3777    fn typed_signal_float64_round_trips() {
3778        let _serial = SIGNAL_STATE.lock().unwrap_or_else(|e| e.into_inner());
3779        unsafe {
3780            let name = CString::new("typed_float_test").unwrap();
3781            assert_eq!(
3782                lumen_signal_set_float64(name.as_ptr(), 2.5),
3783                LumenStatus::Ok
3784            );
3785            let mut out: f64 = 0.0;
3786            assert_eq!(
3787                lumen_signal_get_float64(name.as_ptr(), &mut out),
3788                LumenStatus::Ok
3789            );
3790            assert_eq!(out, 2.5);
3791        }
3792    }
3793
3794    #[test]
3795    fn typed_signal_bool_round_trips() {
3796        let _serial = SIGNAL_STATE.lock().unwrap_or_else(|e| e.into_inner());
3797        unsafe {
3798            let name = CString::new("typed_bool_test").unwrap();
3799            assert_eq!(lumen_signal_set_bool(name.as_ptr(), true), LumenStatus::Ok);
3800            let mut out = false;
3801            assert_eq!(
3802                lumen_signal_get_bool(name.as_ptr(), &mut out),
3803                LumenStatus::Ok
3804            );
3805            assert!(out);
3806        }
3807    }
3808
3809    #[test]
3810    fn typed_signal_color_round_trips() {
3811        let _serial = SIGNAL_STATE.lock().unwrap_or_else(|e| e.into_inner());
3812        unsafe {
3813            let name = CString::new("typed_color_test").unwrap();
3814            let rgba = [0xffu8, 0x88, 0x00, 0xff];
3815            assert_eq!(
3816                lumen_signal_set_color(name.as_ptr(), rgba.as_ptr()),
3817                LumenStatus::Ok
3818            );
3819            let mut out = [0u8; 4];
3820            assert_eq!(
3821                lumen_signal_get_color(name.as_ptr(), out.as_mut_ptr()),
3822                LumenStatus::Ok
3823            );
3824            assert_eq!(out, rgba);
3825        }
3826    }
3827
3828    #[test]
3829    fn typed_signal_get_missing_returns_err_bad_arg() {
3830        let _serial = SIGNAL_STATE.lock().unwrap_or_else(|e| e.into_inner());
3831        unsafe {
3832            let name = CString::new("never_set_typed").unwrap();
3833            let mut out: i64 = -1;
3834            let s = lumen_signal_get_int64(name.as_ptr(), &mut out);
3835            assert_eq!(s, LumenStatus::ErrBadArg);
3836        }
3837    }
3838
3839    #[test]
3840    fn lumen_status_message_returns_known_strings() {
3841        unsafe {
3842            let p = lumen_status_message(LumenStatus::Ok);
3843            assert!(!p.is_null());
3844            let s = CStr::from_ptr(p).to_str().unwrap();
3845            assert_eq!(s, "ok");
3846            let p2 = lumen_status_message(LumenStatus::ErrBadArg);
3847            let s2 = CStr::from_ptr(p2).to_str().unwrap();
3848            assert!(s2.contains("argument"));
3849        }
3850    }
3851
3852    #[test]
3853    fn typed_setter_routes_through_external_property_bus() {
3854        let _serial = SIGNAL_STATE.lock().unwrap_or_else(|e| e.into_inner());
3855        // Round 4 closure: the typed FFI setters push a typed
3856        // `PropertyValue` through `lumen_core::property_store`'s external
3857        // bus. A synthetic drain (running the system against a fresh
3858        // `PropertyStore` resource) confirms the typed cell lands without
3859        // any stringify-on-write round-trip. The string setter takes the
3860        // same path, so it is asserted here rather than from a second
3861        // test: the bus is process-wide, and two tests draining it in
3862        // parallel would race for each other's entries.
3863        use lumen_core::prelude::Schedule;
3864        use lumen_core::prelude::World;
3865        use lumen_core::property_store::{
3866            PropertyKey, PropertyStore, PropertyValue, drain_external_properties,
3867            init_external_properties,
3868        };
3869        init_external_properties();
3870        unsafe {
3871            let name = CString::new("ffi_pre_run_int").unwrap();
3872            assert_eq!(lumen_signal_set_int64(name.as_ptr(), 7777), LumenStatus::Ok);
3873            let name = CString::new("ffi_pre_run_str").unwrap();
3874            let value = CString::new("seeded").unwrap();
3875            assert_eq!(
3876                lumen_signal_set_str(name.as_ptr(), value.as_ptr()),
3877                LumenStatus::Ok
3878            );
3879        }
3880        // Build a tiny world with just the property store and run the
3881        // drain system once. The synthetic schedule stands in for the
3882        // app's per-tick drain wiring.
3883        let mut world = World::new();
3884        world.insert_resource(PropertyStore::default());
3885        let mut schedule = Schedule::default();
3886        schedule.add_systems(drain_external_properties);
3887        schedule.run(&mut world);
3888        let store = world.resource::<PropertyStore>();
3889        let cell = store.get(&PropertyKey::Global(Arc::<str>::from("ffi_pre_run_int")));
3890        assert!(
3891            matches!(cell, Some(PropertyValue::I64(7777))),
3892            "typed FFI setter must land a PropertyValue::I64 in PropertyStore; got {cell:?}"
3893        );
3894        let cell = store.get(&PropertyKey::Global(Arc::<str>::from("ffi_pre_run_str")));
3895        assert!(
3896            matches!(cell, Some(PropertyValue::Str(s)) if &**s == "seeded"),
3897            "the string setter must land a PropertyValue::Str in PropertyStore; got {cell:?}"
3898        );
3899    }
3900
3901    #[test]
3902    fn typed_read_falls_back_to_local_cache_when_bus_drained() {
3903        let _serial = SIGNAL_STATE.lock().unwrap_or_else(|e| e.into_inner());
3904        // Once the bus is drained the FFI typed reads still see the
3905        // value via the local `TYPED_SIGNALS` cache (round 4 keeps the
3906        // cache as the always-authoritative read-back surface). This
3907        // models the post-`lumen_app_run` flow where the runtime drained
3908        // the bus and the FFI caller hits the cache fallback.
3909        unsafe {
3910            let name = CString::new("ffi_cache_fallback_bool").unwrap();
3911            assert_eq!(lumen_signal_set_bool(name.as_ptr(), true), LumenStatus::Ok);
3912        }
3913        // Forcefully drain the external bus snapshot (no PropertyStore
3914        // around in this test) by reading once. Subsequent typed reads
3915        // must still succeed because the cache mirrors every typed set.
3916        let _ = lumen_core::property_store::external_property_snapshot();
3917        unsafe {
3918            let name = CString::new("ffi_cache_fallback_bool").unwrap();
3919            let mut out = false;
3920            assert_eq!(
3921                lumen_signal_get_bool(name.as_ptr(), &mut out),
3922                LumenStatus::Ok
3923            );
3924            assert!(out, "typed read should fall back to cache after bus drain");
3925        }
3926    }
3927
3928    extern "C" fn noop_watch(_name: *const c_char, _value: *const LumenValue, _ud: *mut c_void) {}
3929
3930    #[test]
3931    fn signal_watch_registers_additively() {
3932        let _serial = SIGNAL_STATE.lock().unwrap_or_else(|e| e.into_inner());
3933        // Only the success path is exercised here: the null-argument paths
3934        // call `set_last_error`, which writes the process-global error slot
3935        // and would race the parallel `last_error_thread_local_then_global`
3936        // test. Null-rejection is covered in the headless integration binary
3937        // (a separate process with its own global state).
3938        unsafe {
3939            let name = CString::new("watch_reg_test").unwrap();
3940            let cb: LumenWatchFn = noop_watch;
3941            assert_eq!(
3942                lumen_signal_watch(name.as_ptr(), Some(cb), ptr::null_mut()),
3943                LumenStatus::Ok
3944            );
3945            // A second registration for the same name is additive (accepted).
3946            assert_eq!(
3947                lumen_signal_watch(name.as_ptr(), Some(cb), ptr::null_mut()),
3948                LumenStatus::Ok
3949            );
3950        }
3951        // Two watchers landed for the name.
3952        let reg = signal_watchers().lock().unwrap();
3953        assert_eq!(reg.get("watch_reg_test").map(Vec::len), Some(2));
3954    }
3955
3956    #[test]
3957    fn property_to_lumen_encodes_each_variant() {
3958        let mut keep: Option<CString> = None;
3959        assert_eq!(
3960            property_to_lumen(&PropertyValue::I64(7), &mut keep).kind,
3961            LumenKind::Int
3962        );
3963        assert_eq!(
3964            property_to_lumen(&PropertyValue::Bool(true), &mut keep).kind,
3965            LumenKind::Bool
3966        );
3967        assert_eq!(
3968            property_to_lumen(&PropertyValue::F64(1.5), &mut keep).kind,
3969            LumenKind::Float
3970        );
3971        let s = property_to_lumen(&PropertyValue::Str(Arc::<str>::from("hi")), &mut keep);
3972        assert_eq!(s.kind, LumenKind::String);
3973        assert!(keep.is_some());
3974        // Color packs into a LUMEN_INT as 0xRRGGBBAA.
3975        let c = property_to_lumen(
3976            &PropertyValue::Color(Color::rgba(1.0, 0.0, 0.0, 1.0)),
3977            &mut keep,
3978        );
3979        assert_eq!(c.kind, LumenKind::Int);
3980        assert_eq!(unsafe { c.as_.integer } & 0xff, 0xff); // alpha
3981        assert_eq!((unsafe { c.as_.integer } >> 24) & 0xff, 0xff); // red
3982    }
3983
3984    #[test]
3985    fn status_codes_are_stable() {
3986        // Numeric stability for embedders. Adding variants is fine;
3987        // renumbering breaks ABI.
3988        assert_eq!(LumenStatus::Ok as u32, 0);
3989        assert_eq!(LumenStatus::ErrBadPath as u32, 1);
3990        assert_eq!(LumenStatus::ErrBadArg as u32, 2);
3991        assert_eq!(LumenStatus::ErrRuntime as u32, 3);
3992        assert_eq!(LumenStatus::ErrInternal as u32, 4);
3993        assert_eq!(LumenStatus::ErrParse as u32, 5);
3994        assert_eq!(LumenStatus::ErrCss as u32, 6);
3995        assert_eq!(LumenStatus::ErrAsset as u32, 7);
3996        assert_eq!(LumenStatus::ErrWindow as u32, 8);
3997        assert_eq!(LumenStatus::ErrScript as u32, 9);
3998        assert_eq!(LumenStatus::ErrIo as u32, 10);
3999        assert_eq!(LumenStatus::ErrInvalidHandle as u32, 11);
4000        assert_eq!(LumenStatus::ErrInvalidValue as u32, 12);
4001        assert_eq!(LumenStatus::ErrPanic as u32, 13);
4002        assert_eq!(LumenStatus::ErrBufferTooSmall as u32, 14);
4003    }
4004
4005    #[test]
4006    fn typed_signal_str_round_trips() {
4007        let _serial = SIGNAL_STATE.lock().unwrap_or_else(|e| e.into_inner());
4008        unsafe {
4009            let name = CString::new("ffi_get_str_test").unwrap();
4010            let value = CString::new("hello world").unwrap();
4011            assert_eq!(
4012                lumen_signal_set_str(name.as_ptr(), value.as_ptr()),
4013                LumenStatus::Ok
4014            );
4015            // Size query: null buffer reports the required capacity.
4016            let mut needed: usize = 0;
4017            assert_eq!(
4018                lumen_signal_get_str(name.as_ptr(), ptr::null_mut(), 0, &mut needed),
4019                LumenStatus::ErrBufferTooSmall
4020            );
4021            assert_eq!(needed, "hello world".len() + 1);
4022            // Fill.
4023            let mut buf = vec![0i8; needed];
4024            let mut out_len: usize = 0;
4025            assert_eq!(
4026                lumen_signal_get_str(name.as_ptr(), buf.as_mut_ptr(), buf.len(), &mut out_len),
4027                LumenStatus::Ok
4028            );
4029            assert_eq!(out_len, "hello world".len());
4030            let got = CStr::from_ptr(buf.as_ptr()).to_str().unwrap();
4031            assert_eq!(got, "hello world");
4032        }
4033    }
4034
4035    #[test]
4036    fn clear_leaves_an_empty_string_signal() {
4037        let _serial = SIGNAL_STATE.lock().unwrap_or_else(|e| e.into_inner());
4038        unsafe {
4039            let name = CString::new("ffi_clear_str_test").unwrap();
4040            let value = CString::new("filled").unwrap();
4041            assert_eq!(
4042                lumen_signal_set_str(name.as_ptr(), value.as_ptr()),
4043                LumenStatus::Ok
4044            );
4045            assert_eq!(lumen_signal_clear(name.as_ptr()), LumenStatus::Ok);
4046            let mut out_len: usize = 0;
4047            let mut buf = [0i8; 8];
4048            assert_eq!(
4049                lumen_signal_get_str(name.as_ptr(), buf.as_mut_ptr(), buf.len(), &mut out_len),
4050                LumenStatus::Ok
4051            );
4052            assert_eq!(out_len, 0);
4053        }
4054    }
4055
4056    #[test]
4057    fn typed_getters_reject_a_type_mismatch() {
4058        let _serial = SIGNAL_STATE.lock().unwrap_or_else(|e| e.into_inner());
4059        unsafe {
4060            let name = CString::new("ffi_mismatch_test").unwrap();
4061            let value = CString::new("not a number").unwrap();
4062            assert_eq!(
4063                lumen_signal_set_str(name.as_ptr(), value.as_ptr()),
4064                LumenStatus::Ok
4065            );
4066            let mut out: i64 = 0;
4067            assert_eq!(
4068                lumen_signal_get_int64(name.as_ptr(), &mut out),
4069                LumenStatus::ErrBadArg
4070            );
4071        }
4072    }
4073
4074    #[test]
4075    fn array_signal_len_and_field_round_trip() {
4076        let _serial = SIGNAL_STATE.lock().unwrap_or_else(|e| e.into_inner());
4077        unsafe {
4078            // Build a LUMEN_ARRAY of two LUMEN_MAP rows: [{name:"a"},{name:"b"}].
4079            let key = CString::new("name").unwrap();
4080            let va = CString::new("a").unwrap();
4081            let vb = CString::new("b").unwrap();
4082            let row0 = [LumenMapEntry {
4083                key: key.as_ptr(),
4084                value: LumenValue {
4085                    kind: LumenKind::String,
4086                    as_: LumenValueData {
4087                        string: va.as_ptr(),
4088                    },
4089                },
4090            }];
4091            let row1 = [LumenMapEntry {
4092                key: key.as_ptr(),
4093                value: LumenValue {
4094                    kind: LumenKind::String,
4095                    as_: LumenValueData {
4096                        string: vb.as_ptr(),
4097                    },
4098                },
4099            }];
4100            let items = [
4101                LumenValue {
4102                    kind: LumenKind::Map,
4103                    as_: LumenValueData {
4104                        map: LumenMapView {
4105                            entries: row0.as_ptr(),
4106                            len: 1,
4107                        },
4108                    },
4109                },
4110                LumenValue {
4111                    kind: LumenKind::Map,
4112                    as_: LumenValueData {
4113                        map: LumenMapView {
4114                            entries: row1.as_ptr(),
4115                            len: 1,
4116                        },
4117                    },
4118                },
4119            ];
4120            let arr = LumenValue {
4121                kind: LumenKind::Array,
4122                as_: LumenValueData {
4123                    array: LumenArrayView {
4124                        items: items.as_ptr(),
4125                        len: 2,
4126                    },
4127                },
4128            };
4129            let name = CString::new("ffi_array_test").unwrap();
4130            assert_eq!(lumen_signal_set_array(name.as_ptr(), &arr), LumenStatus::Ok);
4131
4132            let mut len: usize = 0;
4133            assert_eq!(
4134                lumen_signal_array_len(name.as_ptr(), &mut len),
4135                LumenStatus::Ok
4136            );
4137            assert_eq!(len, 2);
4138
4139            let mut buf = [0i8; 8];
4140            let mut out_len: usize = 0;
4141            assert_eq!(
4142                lumen_signal_array_get_field(
4143                    name.as_ptr(),
4144                    1,
4145                    key.as_ptr(),
4146                    buf.as_mut_ptr(),
4147                    buf.len(),
4148                    &mut out_len
4149                ),
4150                LumenStatus::Ok
4151            );
4152            assert_eq!(CStr::from_ptr(buf.as_ptr()).to_str().unwrap(), "b");
4153        }
4154    }
4155}