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