candela scripting reference¶
Every builtin the candela script host registers, with its signature, parameters, and behaviour. candela is Lumen's default script language; for the task-oriented introduction see Scripting. The candela language itself is documented separately at /candela/; this page covers only the Lumen surface.
The same surface in the other hosts, under the same names:
rhai, lua. One structural difference
shapes those names: a candela host function is keyed by name alone and cannot
be overloaded on arity, so a call with two forms gets two names on every host:
page(path) and page_current(), computed_style(prop) and
computed_style_all().
Selecting the host¶
Each script file picks its host from its own extension: .cdl runs under
candela, .lua under Lua, .rhai under Rhai. Files of one language combine
into a single program; an app that ships two languages runs both hosts, sharing
signals but not functions.
An inline <script> block has no extension. It joins the app's one external
language when there is exactly one, and candela otherwise.
lumen.toml overrides all of it and puts every script on one engine:
Reaching the builtins¶
candela resolves host functions through a typed host "lumen" { ... } block. One
import line declares the whole Lumen surface:
The import is replaced with the declarations before compilation, so no
lumen.cdl file is read from disk. Without the import (or a hand-written host
block), the source still compiles, and calling a builtin fails at run time:
A call to a name the namespace does not have reads differently. The import is in place; the function is not on the surface, and where the same name lives in a namespace the app can reach, the message names it:
the `lumen` namespace has no `read_file` (called as `lumen::read_file`)
the `lumen` namespace has no `data_dir` (called as `lumen::data_dir`); `files::data_dir` exists
Both surface at load for any function candela can type from that function's own declaration, which is every handler whose parameters are annotated and every function that takes none. A handler with a bare parameter is compiled by the first call that reaches it, and reports there instead.
Write the import in every .cdl file that uses the surface. An app's candela
files join into one program and the declarations land once for the whole
program, so a repeated import costs nothing.
The prelude also declares the window, document, and history namespaces and
defines the Node, Event, Signal<T>, and ArraySignal method wrappers
described below, plus the Color record a color signal reads back as.
Types in signatures are candela types: int, float, bool, string, arrays
(int[]), and maps ({string: float}). A builtin with no return type returns
null.
Dynamically-shaped builtins¶
A few builtins carry a value with no single concrete type: an array signal's
records, an http request, a parse_json result. Those are declared with a
... argument list and, where they return a value, the any return type. The
tables below give what each one takes and returns; the prelude declares them, so
an app calls them like any other builtin.
Read an any result with candela's as_map, as_list, as_str, as_int,
as_float, and as_bool downcasts, and test it with is_map, is_list,
is_null, and their siblings.
A downcast collection keeps its entries typed any, which is what a parsed
document needs: as_map and as_list hand back a collection that takes an
insert or a push of any type, in any order, with no wrapper function in
between. The same goes the other way, so an entry read back out is an any and
needs its own downcast before you use it.
Writing arguments¶
Two candela rules shape how you write a call. Each has the same remedy: bind the value to a variable first.
- A map literal holds one value type.
{"id": "a", "n": 1}is rejected; write{"id": "a", "n": "1"}, or build the value fromparse_json. - A builtin call nested inside another builtin's argument list mislays its arguments.
let rows = signal_array("rows");
let row = {"id": "a", "title": "First"};
rows.push(row);
lumen::signal_array_push("rows", {"id": "b"}); // literal straight to a builtin
let n = lumen::signal_array_len("rows"); // not nested in the next call
signal<int>("row_count").set(n);
The candela standard library¶
The toolchain carries candela's own standard library, so a script imports a module by name and gets the functions the candela documentation describes:
The array methods (arr.map(f), arr.sum(), and the rest) come from
std/list, which the compiler loads whether or not a program imports anything.
The modules are read from disk when the program compiles, out of a libs/
directory beside the running executable. lumenc package copies that directory
into the folder it writes, so a shipped app resolves the same imports. A web
build compiles ahead of time, so the text modules travel into the browser
inside the compiled image; std/math, std/random, and std/time bind a C
library the browser cannot open, and an image naming one is refused whole.
Lifecycle hooks¶
Define these as free functions. Each is optional; a missing hook is a no-op.
| Hook | Fires |
|---|---|
on_start() |
Once at app construction, before the first tick. No element is queryable yet: node_get_by_id returns 0. |
on_ready() |
Once per mount, on the first tick after the element tree is published. Queries resolve here. Re-armed after a hot reload, so it runs again on the fresh tree. |
on_close() |
On an OS close request, before teardown. Return false to veto the close and keep the window open. |
on_archive_done(tag: string, dest: string, count: int) |
When an extraction started through the lumen-archive module finishes; count is the number of files written. |
on_archive_error(tag: string, message: string) |
When an extraction started through the lumen-archive module is refused or fails. |
on_audio_end(path: string) |
When a track played through the lumen-audio module reaches its end; path is the path passed to audio_play. |
main() |
candela's module entry point, run once at compile time. Keep it empty unless the app needs module-level setup. |
Across a hot reload the signal values, the on(...) routing table, and live
event bindings are preserved; the recompiled program picks them up.
Event handlers¶
Each event dispatches to a global handler function named below. Handlers are optional.
Annotate a handler's parameters with the types the table gives:
Running from source works either way, because the compiler takes a bare parameter's type from the first call. A compiled app has no compiler to do that, so it records a call trampoline for each handler at build time and only records one for a handler in the app's own script file whose parameters are all annotated. A handler left bare compiles, ships, and is then never called, and so is one an imported script library declares, whatever it annotates: write the handlers in the app's own file.
Annotation is also what puts the handler's body in front of lumenc check.
Nothing in the script calls a handler, so the declared types are the only ones
its body can be compiled at ahead of the event: check and build compile the
body of every handler that annotates its parameters and report a mistake in it
the way they report one in main. A bare parameter defers that to the first
event, which is where the error then appears, as a runtime failure of the
handler.
| Handler | Arguments |
|---|---|
on_click(id) |
Element id. Suppressed when a double-click fires on the same element in the same tick. |
on_double_click(id) |
Element id. |
on_long_press(id) |
Element id. |
on_toggle(id, checked) |
Element id, bool. |
on_slider(id, value) |
Element id, float. |
on_text_input(id, text) |
Element id, current text. Fires on every edit that changes the text, and once more on Enter commit. |
on_drop(target_id, payload) |
Drop-zone id, the source's text payload. |
on_drag_start(source_id, payload) |
Drag-source id, its text payload. |
on_file_dropped(id, path) |
Element id, dropped file path. |
on_file_picked(tag, path) |
Dialog tag, chosen path. Empty path on cancel. |
on_files_picked(tag, paths) |
Dialog tag, paths joined with \|. |
on_folder_picked(tag, path) |
Dialog tag, chosen folder. |
on_hotkey(name) |
Registered hotkey name. |
on_hotkey_release(name) |
Registered hotkey name, on release of the chord. |
on_notification_action(id, action_id) |
Notification id, pressed button's action id. |
on_clipboard(tag, text) |
Read tag, clipboard text. Empty when the clipboard holds no text. |
on_menu(id) |
Menu item id. |
on_tray(id) |
Tray icon id. |
on_recent_files(tag, paths) |
Request tag, recorded paths joined with \|, most recent first. |
on_autostart_enabled(tag) |
Request tag; the app is set to launch at login. |
on_autostart_disabled(tag) |
Request tag; the app is not set to launch at login. |
on_second_instance(args) |
A second launch's command-line arguments, joined with \|. |
on_dialog_accepted(id) |
Dialog id. |
on_dialog_rejected(id) |
Dialog id. |
on_timer(name) |
Timer name. |
on_fetch(tag, body) |
Request tag, response body (2xx only). |
on_fetch_error(tag, message) |
Request tag, error text (transport failure or non-2xx). |
on_download_progress(tag, received, total) |
Download tag, bytes so far, the size the server declared or -1. Fires only for a transfer started by the lumen-download module. |
on_download_done(tag, path) |
Download tag, the path the downloaded file was written to. |
on_download_error(tag, message) |
Download tag, error text. |
on_process_stdout(tag, line) |
Tag given to process::start, one line the program wrote to standard output. |
on_process_stderr(tag, line) |
Tag, one line the program wrote to standard error. |
on_process_exit(tag, code) |
Tag, the program's exit code as an int. Always the last event for a tag. |
Per-element routing¶
Routes one (event, id) pair to the script function named handler, bypassing
the global handler for that pair only. event is the name without the on_
prefix ("click", "toggle", "timer", "file_picked", ...). A handler
registered for save also matches template-instance ids ending in :save.
The routed function takes what the global handler takes, so a click route is
fn handle_save(id: string) and a toggle route is
fn handle_flag(id: string, checked: bool). Annotate it the same way.
Returns the id of the sibling suffix inside the same template instance as
source: local_id("user-card:save", "status") is "user-card:status". A
source with no instance prefix returns suffix unchanged. Use it inside a
handler to reach the other elements of the instance that fired it.
Signals¶
Signals are the named reactive cells markup binds to with bind-text and
friends.
| Builtin | Returns | Behaviour |
|---|---|---|
lumen::signal_get(name: string) |
string |
Read as a string. Empty when never written. |
lumen::signal_set(name: string, value: string) |
Write a string. | |
lumen::signal_get_int(name: string) |
int |
Read as an integer. 0 on miss or a non-numeric value. |
lumen::signal_set_int(name: string, value: int) |
Write an integer. | |
lumen::signal_get_float(name: string) |
float |
Read as a float. 0.0 on miss or a non-numeric value. |
lumen::signal_set_float(name: string, value: float) |
Write a float. | |
lumen::signal_get_bool(name: string) |
bool |
Read as a boolean. false on miss or an unparseable value. |
lumen::signal_set_bool(name: string, value: bool) |
Write a boolean. | |
lumen::signal_set_color(name: string, hex: string) |
Write a hex color: #rgb, #rgba, #rrggbb, or #rrggbbaa. The alpha-free forms are opaque. Unparseable input is ignored. |
|
lumen::signal_get_color(name: string) |
{string: int} |
Read a color as an { r, g, b, a } map of 0-255 channels. Empty when the signal holds no color. |
lumen::signals_all() |
{string: string} |
The whole signal set as a name-to-value map. |
The four typed setters are the marshalling layer the signal handle below calls; reach for the handle instead, which carries the type once instead of in every call name.
A getter converts across the scalar types: an integer cell read through
signal_get_float yields the same number as a float, and a string cell parses.
A color signal is a typed cell, not a string: CSS reads it as a color, so
writing "#ff8800" to accent recolors everything bound to accent.
The signal handle¶
signal<T>(name) is the spelling to reach for. The type argument says what the
cell holds, and that is what makes one get / set pair enough: get returns
that type and set takes it.
import "lumen.cdl";
fn on_start() {
signal<int>("clicks").set(0);
}
fn bump(ev: int) {
let clicks = signal<int>("clicks");
clicks.set(clicks.get() + 1);
}
fn main() {}
| Handle | get returns |
set takes |
|---|---|---|
signal<int>(name) |
int |
int |
signal<float>(name) |
float |
float |
signal<bool>(name) |
bool |
bool |
signal<string>(name) |
string |
string |
signal<Color>(name) |
Color, the { r, g, b, a } record of 0-255 channels. A cell holding no color reads as transparent black. |
the hex form: #rgb, #rgba, #rrggbb, or #rrggbbaa |
signal<any>(name) |
any |
string |
The handle holds only the name and calls the builtins above, so it reaches the same cells they do. There is no default value: seed a cell by writing it once.
Write the type argument wherever the cell's type is known: it is what makes
get hand back that type and set take it. A bare signal("clicks") pins
nothing, and a type parameter nothing pins is any, so it is the same handle
as signal<any>("clicks"): reads come back as any and writes go through the
string sink. That is the form for a cell whose type is not fixed.
Array signals¶
An array signal is the reactive list <for each="name"> renders, one element
per record. A record is a string-keyed map whose fields <for> binds by name;
an item that is not a map is carried as a one-field value record.
| Builtin | Returns | Behaviour |
|---|---|---|
lumen::signal_array_set(name: string, items: any) |
Replace the whole array with a list of records. | |
lumen::signal_array_push(name: string, item: any) |
Append one record. | |
lumen::signal_array_get(name: string, index: int) |
any |
One record by zero-based index; null when out of range. |
lumen::signal_array_all(name: string) |
any |
Every record, as a list. |
lumen::signal_array_len(name: string) |
int |
Record count. |
lumen::signal_array_remove(name: string, index: int) |
Drop the record at index. An out-of-range index does nothing. |
|
lumen::signal_array_clear(name: string) |
Empty the array. |
signal_array(name) wraps the name so the same calls read as methods:
import "lumen.cdl";
fn on_start() {
let rows = signal_array("rows");
let first = {"id": "a", "title": "Alpha"};
rows.push(first);
let back = as_map(rows.get(0));
}
fn main() {}
Methods: set, push, get, all, len, remove, clear.
Field values are stringified on the way into the rendered row, so a record built
from a map literal carries strings. A record from parse_json, a response body,
or the host side keeps every field's own type when you read it back with
signal_array_get.
Validation state¶
Whether the element with that id currently passes validation. The runtime
writes the backing valid:<id> signal each tick from the element's validation
rules; an element that carries none reads as valid.
Derived signals¶
Registers a computed signal name, recomputed by the script function named f
whenever any signal in deps changes. f receives the dependency values in
deps order and returns the new value. A host function takes only the values
that marshal across the boundary, and a candela function is not one of them, so
the recompute body is referenced by name rather than passed.
Declare each parameter as the type the cell holds, or as any. A cell written
through signal<int> arrives as an int, signal<float> as a float,
signal<bool> as a bool, and signal<string> as a string; the conversions
a getter performs do not apply here, so fn f(n: float) on an int cell is a
type error. any takes whatever the cell holds and is the safe choice when a
signal's type is not fixed:
A derivation runs once after registration, then on every change to a dependency. Derived-of-derived chains settle within the same tick. A derivation that errors, including an internal engine crash mid-evaluation, is contained the same way a failed call is: the app keeps running, that one recompute is skipped, and the derivation is retried on the next tick, so a parameter type that never matches fails and logs on every tick for the life of the app.
Timers¶
| Builtin | Behaviour |
|---|---|
lumen::set_timeout(name: string, ms: int) |
One-shot timer; fires on_timer(name) after ms milliseconds. |
lumen::set_interval(name: string, ms: int) |
Repeating timer; fires on_timer(name) every ms milliseconds. |
lumen::cancel_timer(name: string) |
Cancel the named timer. |
Timer names are unique: setting a timer with an existing name replaces it. Negative delays clamp to zero. Cancelling from inside the timer's own handler takes effect before the next fire.
Frame callbacks¶
| Builtin | Behaviour |
|---|---|
lumen::request_frame() |
Ask for one on_frame(dt) call on the next tick. |
One request buys one callback. A handler that wants to keep animating asks
again from inside itself, and an animation that is finished simply stops
asking, which parks the app. dt is the seconds since the previous callback,
capped so a stalled tick slows an animation instead of teleporting it, and
zero on the frame that starts a loop because no interval has passed yet.
fn on_ready() {
lumen::request_frame();
}
fn on_frame(dt: float) {
let angle = signal<float>("angle");
angle.set(angle.get() + dt * 2.0);
lumen::request_frame();
}
Use this rather than a repeating timer for anything visual: a timer fires on a wall-clock schedule that has nothing to do with when the app draws.
Query and traverse the element tree¶
Node handles are int ids; 0 means "no node". Reads resolve against the
snapshot rebuilt each tick, so a handle to a removed element reads as invalid.
| Builtin | Returns | Behaviour |
|---|---|---|
lumen::node_query(selector: string) |
int[] |
Every element matching a CSS selector, document order. |
lumen::node_get_by_id(id: string) |
int |
Element with that id, or 0. |
lumen::node_document() |
int |
The document root. |
lumen::node_parent(node: int) |
int |
Parent, or 0. |
lumen::node_first_child(node: int) |
int |
First child, or 0. |
lumen::node_last_child(node: int) |
int |
Last child, or 0. |
lumen::node_next(node: int) |
int |
Next sibling, or 0. |
lumen::node_prev(node: int) |
int |
Previous sibling, or 0. |
lumen::node_children(node: int) |
int[] |
Children in document order. |
lumen::node_closest(node: int, selector: string) |
int |
Nearest ancestor-or-self matching the selector, or 0. |
lumen::node_valid(node: int) |
bool |
Whether the handle is in the current snapshot. |
Mutate the element tree¶
Mutations queue a command applied later in the same tick.
| Builtin | Returns | Behaviour |
|---|---|---|
lumen::node_spawn(tag: string) |
int |
Create a detached element. The handle is valid for the rest of the tick; attach it before the tick ends. |
lumen::node_clone_deep(source: int) |
int |
Deep-clone a subtree into a fresh detached element. |
lumen::node_set_attr(node: int, name: string, value: string) |
Set an attribute. id, class, text, and disabled route to their typed component; anything else lands in the attribute map. |
|
lumen::node_remove_attr(node: int, name: string) |
Remove an attribute. | |
lumen::node_set_id(node: int, id: string) |
Set the id attribute. |
|
lumen::node_set_text(node: int, text: string) |
Replace the text content. | |
lumen::node_set_inner_markup(node: int, markup: string) |
Replace the children with a parsed markup fragment. Do not feed untrusted content. A no-op when the app runs from a precompiled artifact, which links no parser. | |
lumen::node_class_add(node: int, class: string) |
Add one class. | |
lumen::node_class_remove(node: int, class: string) |
Remove one class. | |
lumen::node_class_toggle(node: int, class: string) |
Toggle one class. | |
lumen::node_set_class(node: int, classes: string) |
Replace the whole class list. | |
lumen::node_set_style(node: int, name: string, value: string) |
Set one inline style property. | |
lumen::node_style_remove(node: int, name: string) |
Remove one inline style property. | |
lumen::node_append(parent: int, child: int) |
Append child under parent. |
|
lumen::node_insert_before(parent: int, child: int, reference: int) |
Insert child before reference under parent. A reference of 0 appends. |
|
lumen::node_set_parent(node: int, parent: int) |
Reparent node under parent. |
|
lumen::node_move_to(node: int, parent: int) |
Same as node_set_parent. |
|
lumen::node_replace_with(old: int, new: int) |
Replace old with new, despawning old's subtree. |
|
lumen::node_remove(node: int) |
Detach and despawn the element and its subtree. |
Markup blocks¶
An lmn!( ... ) block is markup a candela function returns. It compiles to a
fragment when the app is built, and the call instantiates that fragment, so a
running app parses no markup. For what goes inside a block and how components
compose, see composition.
| Builtin | Returns | Behaviour |
|---|---|---|
lumen::fragment_spawn(key: string, args: string[], children: int[]) |
int |
Instantiate the compiled fragment key into a detached node. args is flattened name/value pairs; children are the nodes its slots take, in slot order. This is what an lmn! block expands to; call it directly only against a key you know. |
lumen::mount(node: int) |
Put node at the app root. |
The block itself is the surface to write against:
fn Home(name: string) {
return lmn!(<label class="home" text="home for $name"/>);
}
fn on_ready() {
lumen::mount(Home("bob"));
}
fn main() {}
A block obeys three rules the compiler enforces:
- One root element. A call returns one node.
- Arguments are static.
$namesubstitutes once, when the instance is built. Something that changes while the app runs is abind-*attribute inside the block. - A handle is valid for the tick it was minted in. Attach what a component
returns before the tick ends, the same as a
node_spawnhandle.
A component element inside a block is a use site, not a call: the build
resolves it against the component it names. Where that component returns its
block and nothing else, and reads only its own parameters, the block is put in
the tree at build time. Where it works a value out or picks between blocks, the
build leaves a marker and the runtime fills it by calling the function on the
first tick, before the tree is drawn. A .lmn file writes the same element and means the same thing; see
composition.
lumenc check and lumenc build both read every block in the app's scripts, so
a malformed one fails ahead of the run.
Read element state¶
| Builtin | Returns | Behaviour |
|---|---|---|
lumen::node_get_attr(node: int, name: string) |
string |
One attribute value; empty when absent. |
lumen::node_text(node: int) |
string |
Text content. |
lumen::node_id(node: int) |
string |
The id attribute. |
lumen::node_class_contains(node: int, class: string) |
bool |
Whether the class list contains class. |
lumen::node_style_get(node: int, prop: string) |
string |
One inline style override. |
lumen::node_computed_style(node: int, prop: string) |
string |
One resolved style property after the cascade. |
lumen::node_computed_style_all(node: int) |
{string: string} |
Every resolved style property. Spelled apart from node_computed_style because a candela host function takes one arity per name. |
lumen::node_inline_style(node: int) |
{string: string} |
Every inline style override. |
lumen::node_attrs(node: int) |
{string: string} |
Every attribute. |
lumen::node_classes(node: int) |
string[] |
The class list. |
Introspection¶
| Builtin | Returns | Behaviour |
|---|---|---|
lumen::node_rect(node: int) |
{string: float} |
Post-layout border box: x, y, width, height, client_x, client_y. Local x / y are relative to the parent; client_* are window coordinates. |
lumen::node_content_rect(node: int) |
{string: float} |
Same keys, for the content box (padding and border removed). |
lumen::node_scroll(node: int) |
{string: float} |
x, y, max_x, max_y. |
lumen::node_is_visible(node: int) |
bool |
Effective visibility. |
lumen::node_z_index(node: int) |
int |
Resolved stacking order. |
lumen::node_entity_id(node: int) |
{string: int} |
index and generation of the backing entity. |
lumen::node_components(node: int) |
string[] |
Names of the introspectable components on the element. |
lumen::node_component(node: int, name: string) |
{string: string} |
Field map of one component. Empty for an absent or non-introspectable name. |
lumen::node_outer_markup(node: int) |
string |
The subtree serialized to markup text. |
lumen::node_inner_markup(node: int) |
string |
The children serialized to markup text. |
lumen::matched_rules(node: int) |
any |
The stylesheet rules that matched the node, ascending in cascade order (last wins). Each record is { selector, specificity, source, source_order, declarations }, where specificity is a three-int list and declarations a property-to-value map. |
lumen::dump_tree() |
string |
Whole-tree structural dump. |
lumen::pointer_state() |
{string: string} |
x, y, inside, buttons, shift, ctrl, alt, super. Values are stringified. |
lumen::frame_info() |
{string: float} |
frame, dt_ms, dirty_count. |
The introspectable components are LayoutBox, Visuals, Opacity, ZIndex,
Visible, TextContent, LumenClasses, LumenAttributes, InlineStyle, and
Style.
Element events¶
Bind a handler to one element and one event type. Binding returns a token; pass
it to event_off to unbind.
| Builtin | Returns | Behaviour |
|---|---|---|
lumen::event_on(node: int, event_type: string, handler: string) |
int |
Bind handler (a function name) for the bubble phase. Returns the off token, or 0 for an unknown node. |
lumen::event_on_capture(node: int, event_type: string, handler: string) |
int |
Same, for the capture phase. |
lumen::event_off(token: int) |
Unbind. |
The handler takes one parameter, the event id, for every event type:
fn handle_click(ev: int). Annotate it the same way, and read the event
through the accessors below:
| Accessor | Returns | Value |
|---|---|---|
lumen::event_target(ev: int) |
int |
The element the event originated on. |
lumen::event_current_target(ev: int) |
int |
The element whose handler is running. |
lumen::event_type(ev: int) |
string |
Event type name. |
lumen::event_key(ev: int) |
string |
Key name for keyboard events. |
lumen::event_value(ev: int) |
string |
Text value for input / change / submit. |
lumen::event_button(ev: int) |
int |
0 primary, 1 middle, 2 secondary. |
lumen::event_x(ev: int) / lumen::event_y(ev: int) |
float |
Pointer position relative to the target. |
lumen::event_client_x(ev: int) / lumen::event_client_y(ev: int) |
float |
Pointer position in window coordinates. |
lumen::event_delta_x(ev: int) / lumen::event_delta_y(ev: int) |
float |
Wheel delta. |
lumen::event_shift(ev: int) / _ctrl / _alt / _super |
bool |
Modifier state. |
lumen::event_prevent_default(ev: int) |
Cancel the default action. | |
lumen::event_stop_propagation(ev: int) |
Stop the event reaching further elements. | |
lumen::event_stop_immediate_propagation(ev: int) |
Stop the event entirely, including other handlers on this element. |
Event types¶
click, dblclick, pointerdown, pointerup, pointermove, pointerenter,
pointerleave, wheel, keydown, keyup, input, change, focus, blur,
submit, scroll.
Dispatch runs capture (root down to the target), then the target, then bubble
(target up to the root). focus, blur, pointerenter, pointerleave, and
scroll do not bubble.
input fires per keystroke: every edit that changes the text raises one,
carrying the buffer as it stands after that edit. A caret move raises nothing.
change and submit come from the commit signal instead, so they fire when the
field is committed with Enter. Only click has a default action (link
navigation); prevent_default on a click skips it.
Method sugar¶
The prelude wraps a raw handle in a Node or Event struct so calls read as
methods. The free functions above keep working on raw handles.
import "lumen.cdl";
fn on_ready() {
let list = get_by_id("list");
let row = create("row");
row.add_class("item");
row.set_text("hello");
list.append(row);
row.on("click", "handle_row");
}
fn handle_row(ev: int) {
let e = event(ev);
e.prevent_default();
}
fn main() {}
Constructors: node(handle), event(handle), wrap_nodes(handles),
create(tag), get_by_id(id), document_node(), query(selector).
Node methods mirror the node_* builtins with the prefix dropped, except
for the class calls and the whole-map style read, which carry the DOM-style
names every host uses: exists, valid, parent, first_child,
last_child, next, prev, children, closest, clone_deep, set_attr,
remove_attr, set_id, set_text, set_inner_markup, add_class,
remove_class, toggle_class, set_class, set_style, style_remove,
remove, append, insert_before, set_parent, move_to, replace_with,
get_attr, text, id, has_class, style_get, computed_style,
computed_style_all, is_visible, z_index, classes, components,
outer_markup, inner_markup, on, on_capture. exists() tests the
handle against 0; valid() tests it against the current snapshot.
ArraySignal methods mirror the signal_array_* builtins with the prefix
dropped: set, push, get, all, len, remove, clear. Construct one
with signal_array(name).
Signal<T> has one pair, get and set, and the type argument picks which
builtin each reaches. Construct one with signal<int>(name) and its siblings;
the table under Signals lists what each type argument reads and
writes.
Event methods: target, current_target, event_type, key, value,
button, x, y, client_x, client_y, delta_x, delta_y, shift,
ctrl, alt, super_key, prevent_default, stop_propagation,
stop_immediate_propagation, off.
One more helper builds a list row in a single call:
It creates a tag element, applies cls and text when they are non-empty,
appends the result under parent, and returns the new handle.
window, document, history¶
Each is its own host namespace, declared by the same prelude import.
| Builtin | Returns | Behaviour |
|---|---|---|
window::set_href(path: string) |
Navigate to a page path. | |
window::href() |
string |
The current page path. |
window::reload() |
Re-navigate to the current page. | |
window::title() |
string |
Window title. |
window::set_title(title: string) |
Set the window title. | |
window::dpr() |
float |
Device pixel ratio. |
window::size() |
float[] |
[width, height] in logical pixels. |
window::set_size(width: float, height: float) |
Resize the window, in logical pixels. | |
window::location_path() |
string |
The current page path. |
window::location_query() |
string |
The query string of the request the document is being rendered for, without the leading ?. |
window::location_hash() |
string |
The fragment of the request the document is being rendered for, without the leading #. |
history::back() |
Step one entry back. | |
history::forward() |
Step one entry forward. | |
history::go(delta: int) |
Step delta entries; negative goes back. |
|
document::root() |
int |
The document root. |
document::query(selector: string) |
int[] |
Matching elements, document order. |
document::get_by_id(id: string) |
int |
Element with that id, or 0. |
document::focused() |
int |
The focused element, or 0. |
document::hovered() |
int |
The hovered element, or 0. |
document::create(tag: string) |
int |
Create a detached element. |
Page navigation¶
| Builtin | Returns | Behaviour |
|---|---|---|
lumen::page(path: string) |
Navigate to a page path ("settings", "/user/7", "/"). |
|
lumen::page_current() |
string |
The active page key. Spelled apart from page because a candela host function takes one arity per name. |
lumen::page_back() |
Step one entry back in the page history. | |
lumen::page_forward() |
Step one entry forward. |
See Pages for the file layout these paths resolve against.
Dialogs¶
Each opens a native dialog and delivers the result to the matching handler,
keyed by tag. A cancelled dialog still fires once, with an empty path.
| Builtin | Behaviour |
|---|---|
lumen::pick_file(tag: string) |
Open-file dialog; fires on_file_picked(tag, path). |
lumen::pick_files(tag: string) |
Multi-select dialog; fires on_files_picked(tag, paths) with paths joined by \|. |
lumen::pick_folder(tag: string) |
Folder picker; fires on_folder_picked(tag, path). |
lumen::save_file(tag: string, default_name: string) |
Save dialog seeded with default_name; fires on_file_picked(tag, path). |
lumen::pick_file_filtered(tag: string, spec: string) |
Open-file dialog with extension filters. spec is pipe-separated Label:ext1,ext2 groups; a * extension means no filter. |
OS integration¶
| Builtin | Behaviour |
|---|---|
lumen::notify(title: string, body: string) |
Show an OS notification. |
lumen::notify_ex(id: string, title: string, body: string, options: string, actions: string) |
Show an OS notification. options is pipe-separated key:value entries, where icon takes a themed name or path and urgency takes "low", "normal", or "critical". actions is pipe-separated id:Label buttons; a press fires on_notification_action(id, action_id). An empty string in either position means the defaults. |
lumen::clipboard_write(text: string) |
Put text on the system clipboard. |
lumen::clipboard_read(tag: string) |
Request the clipboard text; fires on_clipboard(tag, text) on the next tick. |
lumen::copy_image(path: string) |
Copy the image at path to the system clipboard. Relative paths resolve against the app directory. |
lumen::save_clipboard_image(path: string) |
Write the clipboard image to path as PNG. Relative paths resolve against the app directory. |
lumen::tray_icon(id: string, icon_path: string, tooltip: string) |
Register or replace a tray icon; clicks fire on_tray(id). An empty tooltip disables it. A relative icon_path resolves against the app directory. |
lumen::tray_icon_menu(id: string, icon_path: string, tooltip: string, menu: string, template: bool) |
Register a tray icon with a context menu, given as pipe-separated id:Label entries where - is a separator; a pick fires on_menu(id). template is the macOS monochrome-icon flag, ignored elsewhere. A relative icon_path resolves against the app directory. |
lumen::unregister_tray(id: string) |
Remove a tray icon. |
lumen::register_hotkey(name: string, accelerator: string) |
Register a global hotkey ("CommandOrControl+S", "Alt+Space", "F11"); fires on_hotkey(name). |
lumen::unregister_hotkey(name: string) |
Remove a global hotkey. |
lumen::open_url(url: string) |
Open url with the default browser, or the mail client for mailto:. |
lumen::open_path(path: string) |
Open path with the platform's default application. Relative paths resolve against the app directory. |
lumen::reveal_path(path: string) |
Show path in the platform's file manager. |
lumen::keep_awake(name: string, reason: string) |
Hold off the screensaver and system sleep under name. Repeating a live name replaces its request. |
lumen::allow_sleep(name: string) |
Release the inhibit registered under name. |
lumen::add_recent_file(path: string, label: string) |
Record path as recently opened. An empty label derives one from the path's file name. |
lumen::list_recent_files(tag: string) |
Request the recent-files list; fires on_recent_files(tag, paths) with paths joined by \|, most recent first. |
lumen::clear_recent_files() |
Remove every entry from the recent-files list. |
lumen::set_autostart(on: bool) |
Enable or disable launching this app at login. |
lumen::query_autostart(tag: string) |
Request the autostart state; fires on_autostart_enabled(tag) or on_autostart_disabled(tag). |
lumen::open_menu(id: string) |
Open menu id by setting the __menu_open:id signal to true. |
lumen::close_menu(id: string) |
Close menu id. |
See OS integration for the markup these pair with.
Styling and theming¶
| Builtin | Behaviour |
|---|---|
lumen::set_class(id: string, classes: string) |
Replace the class list on the element with that id. |
lumen::set_root_class(classes: string) |
Replace the class list on the root element, which drives theme-token selectors. |
lumen::set_color_scheme(name: string) |
Switch the color scheme: "default" (follow the OS), "force-light", "force-dark", "prefer-light", "prefer-dark". An unknown name is ignored with a warning. |
lumen::set_class takes an element id and Node.set_class takes none because
it already has the element. They share a name and do the same thing through
different routes: reach for the free function when all you have is an id, and
the method when you are holding a handle.
Audio¶
These functions come from the lumen-audio runtime module and exist only
when the app declares it under [dependencies] in lumen.toml; see
OS integration.
| Builtin | Behaviour |
|---|---|
lumen::audio_play(path: string) |
Load and play the track at path (app-relative wav or ogg, resolved through the app's asset sources, so a packed archive and lumen://app/... URIs work); resets position to zero. |
lumen::audio_pause() |
Pause, holding position. |
lumen::audio_resume() |
Resume a paused transport. |
lumen::audio_stop() |
Stop and rewind. |
lumen::audio_seek(secs: float) |
Seek to secs, clamped to the track duration. |
lumen::audio_volume(level: float) |
Set output volume in 0.0 to 1.0. |
The module writes the audio_position, audio_duration, and audio_playing
signals each tick, so markup binds to them directly.
Networking¶
Issues an HTTP GET without holding up a tick. A 2xx reply fires on_fetch(tag, body);
a transport failure or non-2xx fires on_fetch_error(tag, message). The reply
is delivered on the tick thread, so a handler may touch signals and the element
tree freely.
Issues any HTTP request. The request is a map; only url and tag are
required. method defaults to GET, and body and timeout_ms are optional.
Each header rides on a header:<Name> key, because a candela map literal holds
one value type:
lumen::http({
"method": "POST",
"url": "https://example.test/items",
"header:Content-Type": "application/json",
"body": "{\"title\":\"First\"}",
"timeout_ms": "2500",
"tag": "create"
});
A request built from parse_json or handed in from the host may instead carry a
nested headers map and an integer timeout_ms; both forms are accepted.
Every completed request fires on_http(tag, response), including a 4xx or 5xx.
The response is a map:
| Field | Type | Value |
|---|---|---|
ok |
bool |
True for a 2xx status. |
status |
int |
HTTP status, 0 on a transport failure. |
headers |
map | Response headers, names lowercased. |
body |
string |
Response body. |
error |
string |
Transport error text; empty when the request completed. |
fn on_http(tag: string, response: any) {
let r = as_map(response);
if as_bool(r.get("ok")) {
lumen::signal_set("status", as_str(r.get("body")));
}
}
Request and response¶
The readers give back what arrived with the request the document is being rendered for, and an empty string when there is none to read; a desktop app has none. The three writers queue an answer that only a server render applies.
| Builtin | Returns | Behaviour |
|---|---|---|
lumen::request_header(name: string) |
string |
The named request header, matched without regard to case. |
lumen::request_cookie(name: string) |
string |
The named request cookie. |
lumen::request_body() |
string |
The request body. |
lumen::response_status(status: int) |
Answer with HTTP status status, clamped to 100..=599. |
|
lumen::response_header(name: string, value: string) |
Set a response header; setting the same name twice replaces the value. | |
lumen::redirect(location: string) |
Answer with a redirect to location, a path or an absolute URL, instead of a document. |
Parsing¶
| Builtin | Returns | Behaviour |
|---|---|---|
lumen::parse_json(json: string) |
any |
Parse JSON into a map, list, or scalar. Null on a parse error. |
lumen::parse_markdown(src: string) |
any |
Parse markdown into a list of block records. |
parse_json returns candela values, not text, so numbers stay numbers and
nesting survives:
let root = as_map(lumen::parse_json(body));
let geo = as_map(root.get("geo"));
let city = as_str(geo.get("city_name"));
candela's own json_parse builtin parses JSON too. Prefer lumen::parse_json:
it interns the keys it produces, so a key longer than six characters is still
reachable with map.get(...).
A parse_markdown block record carries id, kind, level, text, and
lang. kind is h, p, code, li, or hr; level is the heading depth
and 0 elsewhere; lang is the code fence's language and empty elsewhere.
Feed the list straight to an array signal to render a document with <for>.
Inline emphasis keeps its markdown delimiters in text, since a label renders
plain text.
Translation¶
| Builtin | Returns | Behaviour |
|---|---|---|
lumen::t(key: string) |
string |
The active locale's string for key, or key itself when untranslated. |
lumen::tr(key: string) |
string |
Alias for t. |
lumen::set_locale(tag: string) |
null |
Switch the app to the BCP-47 locale tag. Marked text, placeholders, tooltip bodies, format output and the base writing direction follow on the same tick. A locale with no catalogue falls back to the authored text; a tag that is not BCP-47 is logged and ignored. |
lumen::locale() |
string |
The BCP-47 tag of the locale the app is running in, including the one it started in when no script has switched it. |
See Translation and formatting for the catalogue format.
Formatting¶
| Builtin | Returns | Behaviour |
|---|---|---|
lumen::format_number(n: float) |
string |
n written the way the active locale writes numbers. |
lumen::format_currency(amount: float, currency: string) |
string |
amount as money in the ISO-4217 code currency, positioned and rounded the way the locale and the currency ask for. |
lumen::format_date(iso: string) |
string |
The date in iso (YYYY-MM-DD, time optional) written for the locale. |
lumen::format_time(iso: string) |
string |
The time in iso (YYYY-MM-DDTHH:MM[:SS]) written for the locale. |
lumen::format_datetime(iso: string) |
string |
Date and time from iso written for the locale. |
lumen::format_relative(seconds: int) |
string |
seconds from now as the locale says it: -7200 is 2 hours ago. |
A trailing zone on a timestamp (Z, +02:00) is read and dropped; nothing is
converted between time zones. A value the call cannot read comes back as it was
given, the way t returns an untranslated key.
See Translation and formatting for the markup attribute that formats an element's text.
Canvas¶
These functions come from the lumen-canvas runtime module and exist only
when the app declares it under [dependencies] in lumen.toml, along with
the tag it brings:
They live in their own canvas namespace, so no import line reaches
them: the host declares the namespace from what the module registered.
Every call names a canvas by the id on its <canvas>
element. Colours are components in 0..1, angles are radians, and coordinates
are canvas units, which are scaled onto the element's box whenever layout
gives it one the declaration did not ask for.
| Builtin | Returns | Behaviour |
|---|---|---|
canvas::width(id: string) |
int |
The drawing width, in canvas units. |
canvas::height(id: string) |
int |
The drawing height, in canvas units. |
canvas::resize(id: string, width: float, height: float) |
Set the drawing space. This empties the canvas, as writing width on an HTML canvas does. |
|
canvas::clear(id: string) |
Erase everything the canvas holds. | |
canvas::begin_path(id: string) |
Start a new path. | |
canvas::move_to(id: string, x: float, y: float) |
Start a subpath at a point. | |
canvas::line_to(id: string, x: float, y: float) |
Straight segment to a point. | |
canvas::quad_to(id: string, cx: float, cy: float, x: float, y: float) |
Quadratic segment through one control point. | |
canvas::bezier_to(id: string, c1x: float, c1y: float, c2x: float, c2y: float, x: float, y: float) |
Cubic segment through two control points. | |
canvas::arc(id: string, x: float, y: float, radius: float, start: float, end: float) |
Circular arc, angles in radians, joined to the current subpath. | |
canvas::rect(id: string, x: float, y: float, width: float, height: float) |
Add a closed rectangle to the path. | |
canvas::close_path(id: string) |
Close the current subpath back to its start. | |
canvas::fill(id: string) |
Fill the current path. | |
canvas::stroke(id: string) |
Stroke the current path. | |
canvas::fill_rect(id: string, x: float, y: float, width: float, height: float) |
Fill one rectangle, leaving the path alone. | |
canvas::stroke_rect(id: string, x: float, y: float, width: float, height: float) |
Stroke one rectangle, leaving the path alone. | |
canvas::set_fill_rgba(id: string, r: float, g: float, b: float, a: float) |
Set the fill colour from four components. | |
canvas::set_fill_style(id: string, color: string) |
bool |
Set the fill colour from CSS text (#rgb, #rrggbbaa, rgb(), rgba(), the CSS level 1 names, transparent). false when the text is none of those, and the colour is left alone. |
canvas::set_stroke_rgba(id: string, r: float, g: float, b: float, a: float) |
Set the stroke colour from four components. | |
canvas::set_stroke_style(id: string, color: string) |
bool |
Set the stroke colour from CSS text, on the same terms. |
canvas::set_line_width(id: string, width: float) |
Stroke width, in canvas units. | |
canvas::set_line_cap(id: string, cap: string) |
bool |
butt, round, or square. |
canvas::set_line_join(id: string, join: string) |
bool |
miter, round, or bevel. |
canvas::set_global_alpha(id: string, alpha: float) |
Multiply every later draw by this alpha. | |
canvas::save(id: string) |
Push the drawing state: colours, line style, alpha, transform. | |
canvas::restore(id: string) |
Pop it. Popping past the bottom leaves the state alone. | |
canvas::translate(id: string, x: float, y: float) |
Move the origin. | |
canvas::rotate(id: string, radians: float) |
Rotate the transform. | |
canvas::scale(id: string, x: float, y: float) |
Scale the transform. | |
canvas::reset_transform(id: string) |
Drop back to the identity. | |
canvas::set_transform(id: string, a: float, b: float, c: float, d: float, e: float, f: float) |
Replace the transform outright. | |
canvas::set_font(id: string, font: string) |
bool |
"[weight] <size>px [family]", as in "bold 16px Inter". The size is required; false when it is missing. |
canvas::fill_text(id: string, text: string, x: float, y: float) |
Draw text in the fill colour, with (x, y) on the alphabetic baseline, shaped with the app's own fonts. |
A path is stored in canvas units and placed by the transform at the moment it is filled or stroked, so changing the transform between building a path and filling it moves the result. The HTML canvas bakes the transform into each segment as it is added; this is the one place the two differ.
Pixel buffers¶
A canvas is write-only: what it holds is a list of drawing calls bound for the
GPU, not an image, so there is nothing to read a pixel back from. Buffers are
the read-write half - plain CPU pixels a script creates, edits, loads from a
PNG, saves to one, and draws onto a canvas. A pixel is one packed
0xRRGGBBAA integer with straight (not premultiplied) alpha, so what a script
writes is what it reads back.
| Builtin | Returns | Behaviour |
|---|---|---|
canvas::buffer_new(width: int, height: int) |
int |
A new transparent buffer; 0 when it was refused, with the reason on stderr. |
canvas::buffer_free(buffer: int) |
bool |
Release a buffer. false when the handle names none. |
canvas::buffer_width(buffer: int) |
int |
Its width, or 0. |
canvas::buffer_height(buffer: int) |
int |
Its height, or 0. |
canvas::buffer_get_pixel(buffer: int, x: int, y: int) |
int |
One pixel as 0xRRGGBBAA. A pixel outside the buffer reads 0. |
canvas::buffer_set_pixel(buffer: int, x: int, y: int, rgba: int) |
Write one pixel. Outside the buffer, nothing happens. | |
canvas::buffer_get_region(buffer: int, x: int, y: int, width: int, height: int) |
int[] |
A rectangle of pixels, row-major. Pixels off the edge read 0, so the result is always the size that was asked for. |
canvas::buffer_put_region(buffer: int, x: int, y: int, width: int, height: int, pixels: int[]) |
Write a rectangle back. A short array is a partial write. | |
canvas::buffer_fill_rect(buffer: int, x: int, y: int, width: int, height: int, rgba: int) |
Fill a rectangle with one colour. | |
canvas::buffer_load_png(path: string) |
int |
Decode a PNG into a new buffer; 0 when it could not be read. The path resolves against the app directory. |
canvas::buffer_save_png(buffer: int, path: string) |
bool |
Write a buffer out as a PNG. |
canvas::draw_buffer(id: string, buffer: int, x: float, y: float) |
Draw a buffer onto a canvas at its own size. | |
canvas::draw_buffer_scaled(id: string, buffer: int, x: float, y: float, width: float, height: float) |
Draw a buffer stretched into a box. |
Three settings bound what one call may ask for, from the module's config
table: region_cap (pixels in one region call), buffer_pixel_cap (pixels in
one buffer), and buffer_count_cap (buffers at once). A call over a cap
answers empty or 0 and says why on stderr.
Refusals never raise. An unknown canvas id keeps the drawing, in case the
element has not been mounted yet, and reports once if no element ever matches;
an unknown buffer handle reads as an empty buffer. See
<canvas> for the element, its sizing, and what is not
implemented yet.
Filesystem¶
These functions come from the lumen-fs runtime module and exist only when
the app declares it under [dependencies] in lumen.toml:
They live in their own files namespace, so no import line reaches them:
the host declares the namespace from what the module registered.
| Builtin | Returns | Behaviour |
|---|---|---|
files::exists(path: string) |
bool |
Whether anything exists at path. Symlinks are followed. |
files::is_dir(path: string) |
bool |
Whether path is a directory that exists. |
files::list(path: string) |
string[] |
The entry names directly inside path, sorted. Names, not paths, and one level deep. A directory that cannot be read gives an empty array. |
files::mkdir(path: string) |
bool |
Create path and every directory above it. A directory already there is success. |
files::remove(path: string) |
bool |
Remove one file, or one directory that is already empty. A directory holding anything is refused; a path that is not there answers false. |
files::copy(src: string, dest: string) |
bool |
Copy one file, creating the directories dest sits under. A directory source is refused. |
files::read(path: string) |
string |
The utf-8 contents of path, or an empty string when it is not there. |
files::write(path: string, contents: string) |
bool |
Write contents to path. The write is atomic (temp file + rename), so a reader never sees a truncated file. |
files::read_bytes(path: string) |
int[] |
The bytes of path as integers of 0 to 255. A missing file, or one past the cap, gives an empty array. |
files::write_bytes(path: string, bytes: int[]) |
bool |
Write an array of 0-to-255 integers as raw bytes, atomically. A value outside that range refuses the whole write. |
files::data_dir() |
string |
The directory this app saves data in, created when missing. |
candela ships a filesystem library of its own under fs, which is a
different surface: it resolves a path against the process working directory
and raises on failure. The module's files functions resolve against the app
and report instead.
A relative path names a file the app ships, so it reads the same wherever the
app was started from; an absolute path is left alone. Saved state goes under
files::data_dir() instead, because the app directory is read-only once the
app is installed:
data_dir() follows the platform convention for user data ($XDG_DATA_HOME,
else ~/.local/share, on Linux; ~/Library/Application Support on macOS;
%APPDATA% on Windows) and names one directory per app from
[app] id, so two apps on a machine keep their saves apart.
A call that cannot do what it was asked answers false or an empty value and
prints one lumen-fs: line on stderr, so a script branches on the value it
got back. Two cases stay silent, because probing for state that has not been
saved yet is ordinary: reading a file that is not there, and removing one.
files::read_bytes reads up to 8 MiB by default. Raise or lower it with the
module's read_bytes_cap setting, in bytes, between 1 KiB and 256 MiB:
On Windows nothing loads a module beside the executable, so this surface
exists only in a
lumenc package --static
build, which compiles the declared modules in.
Archives¶
These functions come from the lumen-archive runtime module and exist only
when the app declares it under [dependencies] in lumen.toml:
They live in their own archive namespace, so no import line reaches them:
the host declares the namespace from what the module registered.
| Builtin | Returns | Behaviour |
|---|---|---|
archive::extract(src: string, dest: string, tag: string) |
bool |
Unpack the archive at src into the directory dest, creating it. true when the job was taken; false when it was not, which also fires archive_error. |
Both paths resolve against the app directory, and the extraction runs off the
tick loop, so the call answers before any bytes are read. The outcome arrives
as an event keyed by tag:
| Event | Handler | Arguments |
|---|---|---|
archive_done |
on_archive_done |
tag: string, dest: string (the resolved destination), count: int (files written) |
archive_error |
on_archive_error |
tag: string, message: string |
lumen::on("archive_done", tag, fn_name) registers a handler for one job and
wins over the fallback, the same as any other event.
fn on_start() {
archive::extract("themes.zip", "themes", "themes");
}
fn on_archive_done(tag: string, dest: string, count: int) {
signal<int>("files").set(count);
}
fn on_archive_error(tag: string, message: string) {
lumen::signal_set("status", message);
}
zip, tar, and gzip-compressed tar are read. The container is taken from the file's leading bytes, so an archive saved under a name that disagrees with its contents still unpacks; the extension decides only when the bytes say nothing.
What an archive may write is settled before anything is written. An entry
naming an absolute path, climbing out with .., carrying a Windows drive or
UNC prefix, or resolving outside dest ends the whole extraction with an
archive_error naming it, rather than being passed over. Entries written
before the refused one stay on disk, so a destination that took a failed
extraction is one to discard rather than keep using. Symbolic and hard links
are skipped, because a link inside the destination can point outside it once
extraction is over; count is the files written, so it leaves them out.
Existing files are overwritten and missing parent directories are created.
Four extractions run at once by default; a fifth is refused until one
finishes, as is a second job under a tag already running. Change the limit
with the module's max_concurrent setting:
Selecting part of an archive, stripping leading path components, per-entry progress, listing an archive without unpacking it, and writing an archive are not part of this surface.
On Windows nothing loads a module beside the executable, so this surface
exists only in a
lumenc package --static
build, which compiles the declared modules in.
Downloads¶
This function comes from the lumen-download runtime module and exists only
when the app declares it under [dependencies] in lumen.toml:
It lives in its own download namespace, so no import line reaches it: the
host declares the namespace from what the module registered.
| Builtin | Returns | Behaviour |
|---|---|---|
download::to_file(url: string, path: string, tag: string, checksum: string) |
bool |
Start downloading url to path, reporting under tag. True once the transfer is running, false when the call was refused. |
Every argument is required, which is what keeps the call typed; there is no
shorter form that omits the checksum. checksum is sha256: followed by 64
hex digits, sha1: followed by 40, a bare hex string of either length, or an
empty string to check nothing; neither the prefix nor the digits are case
sensitive. Any other spelling fails the call. sha1 is accepted because it is
what most package and artifact registries publish, and the digest here checks
a file against a corrupt transfer rather than standing in for a signature.
The call answers as soon as the transfer starts, and the transfer reports through three handlers keyed by the tag it was given:
| Handler | Arguments |
|---|---|
on_download_progress(tag: string, received: int, total: int) |
Download tag, bytes so far, the size the server declared or -1. |
on_download_done(tag: string, path: string) |
Download tag, the path the file was written to. |
on_download_error(tag: string, message: string) |
Download tag, error text. |
A per-tag on("download_done", tag, handler) registration wins over the
fallback, the same routing every event gets.
Every refusal arrives as download_error under the tag the call named, and
the call itself answers false.
fn on_start() {
download::to_file("https://example.com/pack.zip", "cache/pack.zip", "pack", "");
}
fn on_download_progress(tag: string, received: int, total: int) {
if total > 0 {
signal<int>("pack_progress").set(received * 100 / total);
}
}
fn on_download_done(tag: string, path: string) {
lumen::signal_set("pack_path", path);
}
fn on_download_error(tag: string, message: string) {
lumen::print("pack failed: " + message);
}
A relative path names a file beside the app and an absolute one is left
alone, the way every app path resolves; directories that do not exist yet are
created on the way. The bytes land in a sibling temp file and are renamed into
place only once the body has finished and the checksum has verified, so the
destination never holds a half file and a failed transfer never replaces what
was already there.
Progress is reported at most ten times a second per tag, so a handler that writes a signal cannot become the transfer's bottleneck, and once more with the final count just before the done handler runs.
A reply that is not 2xx fails the download, which is where this parts company
with lumen::fetch: fetch hands a script whatever came back so it can branch
on the status, and a download that did not get the file has nothing to write.
Redirects are followed, up to ten in a chain, and an https URL is verified
against the bundled web-PKI roots.
Three settings, all optional:
| Key | Default | Meaning |
|---|---|---|
timeout_ms |
none | How long a stalled server has to start answering: name resolution, connecting, and the response headers. The body is not on a deadline, so a large download is never cut off part way. |
max_bytes |
none | The largest body accepted. Anything past it fails and writes nothing. |
max_concurrent |
4 |
How many transfers run at once, from 1 to 64. A call past the limit fails. |
[dependencies]
lumen-download = { bundled = true, config = { timeout_ms = 15000, max_concurrent = 2 } }
One tag means one download: a call naming a tag that is already downloading is
refused rather than replacing the transfer under way, because both would report
under the same key. A call with no tag has nowhere to report, so it answers
false and prints one lumen-download: line on stderr.
On Windows nothing loads a module beside the executable, so this surface
exists only in a
lumenc package --static
build, which compiles the declared modules in.
Processes¶
These functions come from the lumen-process runtime module and exist only
when the app declares it under [dependencies] in lumen.toml:
They live in their own process namespace, so no import line reaches them:
the host declares the namespace from what the module registered.
| Builtin | Returns | Behaviour |
|---|---|---|
process::start(cmd: string, args: string[], tag: string) |
bool |
Start cmd with the argument list args, reporting under tag. true once the program is running. |
A cmd with a path separator in it names a program the app ships and resolves
against the app directory; a bare cmd is looked up on PATH. The child runs
in the app directory, reads end of file from its input, and has both its
output streams captured.
The call answers as soon as the program is running. Everything after that arrives as an event carrying the tag, so one handler serves several children:
| Event | Fallback handler |
|---|---|
process_stdout |
on_process_stdout(tag: string, line: string) |
process_stderr |
on_process_stderr(tag: string, line: string) |
process_exit |
on_process_exit(tag: string, code: int) |
A per-child lumen::on("process_exit", tag, handler) registration wins over
the fallback, like every other event.
process_exit is always the last event for a tag. Its code is the program's
own exit code, 128 plus the signal that killed it on Unix, or -1 for an
ending the system does not describe.
A line is what the program wrote before a newline; the newline is not part of it, and the last stretch before the program ends is a line even without one. Bytes that are not utf-8 are replaced, and a line longer than 64 KiB arrives in pieces. There is one handler call per line, so a chatty program calls the handler a lot.
A program that cannot start answers false and prints one lumen-process:
line on stderr. It fires no event at all, because the tag never named a running
program, so branch on the value the call gave back rather than waiting for an
exit that never comes.
There is no way to write to a child's input, no way to end a child from a script, and no per-child environment or working directory. A child is not ended when the app exits: a program still running outlives the app that started it.
On Windows nothing loads a module beside the executable, so this surface
exists only in a
lumenc package --static
build, which compiles the declared modules in.
Diagnostics¶
Stringifies its arguments, joins them with a space, and emits the line through
the script command stream, where lumenc run prints it prefixed with
[script]. candela's own print writes to process stdout instead and bypasses
that stream.
Embedder commands¶
Two builtins emit a command with no built-in effect under lumenc run. They
exist for embedders that read the script command stream directly.
| Builtin | Behaviour |
|---|---|
lumen::add_clicks(n: int) |
Emit an add-clicks command carrying n. |
lumen::set_string(key: string, value: string) |
Emit a set-string command carrying key and value. |
Two more write directly to the element tree by id, without a node handle:
| Builtin | Behaviour |
|---|---|
lumen::set_text(target_id: string, text: string) |
Replace the text content of the element with that id. |
lumen::set_src(target_id: string, path: string) |
Swap the asset path of an <image> at run time. Paths are app-relative. |
Native functions from the embedder¶
An embedder exposes its own functions to the app's script: over the C ABI with
lumen_app_expose (see C ABI), from Rust with the SDK, or from a Rust
plugin. Call them by namespace:
Functions the C ABI and the SDK expose land in the native namespace. A plugin
can choose a name of its own instead, and the script calls it there
(gpio::level(21)).
You do not declare them. candela resolves a host call through a
host "<ns>" { .. } block, and the host writes one for every namespace it bound
from the signatures it was given. A script that spells the block itself keeps
it: the host leaves a namespace the source already declares alone, so an app
written against an earlier release compiles unchanged.
A function described with parameter and return types is declared with them, whatever those types are, and the result is typed as declared, so it composes with operators the way a value of that type does. A call passing the wrong types or the wrong number of arguments is refused when it runs, naming the parameter.
A function with an untyped parameter, a variadic argument list, or an optional
trailing argument has no such spelling: it is declared any name(...), takes
any arguments, and returns any. Read that result with the as_int /
as_str / as_map downcasts, the same way a parse_json result is read.
The name is the plugin's to choose, including one candela's own library uses: a namespaced call takes its type from the block that declares it.
A native function may fail. It raises where it was called, under the kind
host_fn_error and naming the function, so a script handles one the way it
handles any other runtime error:
A plugin can also ship candela source of its own, compiled ahead of the app, to
offer method syntax over its functions. What that looks like is the plugin's
choice; pin(21).level() in place of gpio::level(21) is the shape to expect.
Two things follow from where the declarations come from. An app calling a
function nothing registered fails the compile, naming it, so lumenc check and
lumenc run reject a call meant for an embedder they do not carry. And a
.cdlb build gets the same synthesized declarations a live compile gets, from
whatever was registered on the host before it compiled; lumenc build does
not register a plugin or a runtime module before it compiles your script's
.cdlb, so write the host block by hand in a script you will build to an
artifact, exactly as you would for an embedder lumenc does not carry.
At the artifact's own load, a declaration with no closure behind it fails the load and names the function, rather than the call silently doing nothing. That is what happens when the app was built against a plugin or module that is not installed where it runs.
Rhai and Lua receive the same functions as plain globals (now_ms()), or as a
module or table for a named namespace, because neither needs a declaration to
resolve a call.