Skip to content

Migrating to v0.0.9

archive::extract takes a fourth argument, opts

This affects scripts in apps that declare lumen-archive. extract now takes an options value after the tag, whose include field lists glob patterns for the files to keep. The argument is required, so a three-argument call no longer compiles in candela and raises in Rhai and Lua.

Pass the defaults to keep the previous behaviour of writing every file.

candela, before and after:

archive::extract("themes.zip", "themes", "themes");
archive::extract("themes.zip", "themes", "themes", Default::default());

Rhai:

archive::extract("themes.zip", "themes", "themes");
archive::extract("themes.zip", "themes", "themes", #{});

Lua:

archive.extract("themes.zip", "themes", "themes")
archive.extract("themes.zip", "themes", "themes", {})

To keep only some files, name them, for example archive::ExtractOptions { include: ["*.so"] } in candela. The pattern rules are listed with archive::extract in each host's scripting reference.

Unset inset sides are auto, not 0

This affects apps that set position: absolute without naming every side of inset. An unset side used to be 0, so an absolutely positioned element with no inset stretched over its parent's padding box, and one pinned by only some sides was held at 0 on the others. Unset sides are now auto, as the CSS reference documents and as browsers do: the element keeps its content size and sits against the sides you named.

To keep the stretch, name the sides:

.backdrop { position: absolute; }
.backdrop { position: absolute; inset: 0; }

<overlay> and <dialog> still default to inset: 0. inset also accepts auto per side now, so inset: 4 8 auto auto pins an element to its parent's top-right corner at its natural size.

The layout engine and the window backend come from registries

This affects Rust code that installs the layout engine or runs the window itself: embedders that assemble their own App, custom window or layout backends, and tests that add TaffyLayoutPlugin or WinitPlugin by hand. Apps, scripts, and lumen.toml are not affected.

LayoutEngine and WindowBackend in lumen_core::traits are now traits a launch drives, not markers. LayoutEngine::install(self: Box<Self>, app) adds the engine's systems; the ones that write Transform go in the new lumen_core::layout_backend::LayoutSolve set, which a system reading this tick's boxes orders itself after. WindowBackend::run(self: Box<Self>, app, options, renderers, a11y) runs the app until the window closes and returns lumen_core::traits::WindowError, which replaces WinitError.

Each kind has a registry: LayoutBackends of LayoutBackend { name, priority, engine } and WindowBackends of WindowBackendEntry { name, priority, backend }, beside RenderBackends. All three are lumen_core::backends::Backends<_>, and a backend registers with lumen_core::backends::register_backend, which replaces register_render_backend. The taffy engine and the winit backend register themselves as the layout-taffy and window-winit capabilities in the new Phase::Backends, which runs before the core stack.

TaffyLayout and WinitWindow are gone; TaffyLayoutPlugin is the taffy engine and WinitBackend the winit one. lumen_window_winit::run is no longer public. RedrawScheduler moves to lumen_core::window_backend, and WinitPlugin is replaced by lumen_core::window_backend::WindowCorePlugin, the window-free half every window backend shares. The accessibility bridge reaches a window backend as an opaque lumen_core::traits::A11yBridgeFactory; lumen_a11y_accesskit::bridge_factory() builds the one the winit backend accepts, in place of lumen_a11y_accesskit::winit_bridge.

Before:

use lumen_core::render_backend::register_render_backend;
use lumen_layout_taffy::TaffyLayoutPlugin;
use lumen_window_winit::run;

app.add_plugin(TaffyLayoutPlugin);
app.add_systems(TickStage::LayoutSync, my_system.after(lumen_layout_taffy::sync_layout));
register_render_backend(&mut app, my_renderer);

let a11y: lumen_window_winit::A11yBridgeFactory = Box::new(lumen_a11y_accesskit::winit_bridge);
run(app, options, renderers, Some(a11y))?;

After:

use lumen_core::backends::register_backend;
use lumen_core::layout_backend::LayoutSolve;
use lumen_core::traits::WindowBackend;
use lumen_layout_taffy::TaffyLayoutPlugin;
use lumen_window_winit::WinitBackend;

app.add_plugin(TaffyLayoutPlugin);
app.add_systems(TickStage::LayoutSync, my_system.after(LayoutSolve));
register_backend(&mut app, my_renderer);

Box::new(WinitBackend).run(app, options, renderers, Some(lumen_a11y_accesskit::bridge_factory()))?;

Native painters draw through lumen_paint::PaintTarget

This affects Rust runtime modules and plugins that paint their own pixels through the native-paint seam, with the paint feature of lumen-module.

The paint feature re-exports lumen_paint instead of lumen_render_wgpu, and every render backend hands a painter a lumen_paint::PaintTarget, a boxed Painter, instead of a vello::Scene. A painter that downcasts the target to vello::Scene now finds nothing and draws nothing, and a module that names lumen_module::lumen_render_wgpu no longer compiles. Draw through the Painter trait instead; the same painter then draws on the GPU and on the CPU renderer alike.

Before:

use lumen_module::lumen_render_wgpu::vello::Scene;
use lumen_module::lumen_render_wgpu::vello::kurbo::{Affine, Rect};
use lumen_module::lumen_render_wgpu::vello::peniko::{Color, Fill};

fn paint(&self, ctx: &mut NativePaintCtx<'_>) {
    let transform = Affine::new(ctx.device_transform().coeffs);
    let Some(scene) = ctx.target_as::<Scene>() else { return };
    scene.fill(Fill::NonZero, transform, Color::WHITE, None, &Rect::new(0.0, 0.0, 8.0, 8.0));
}

After:

use lumen_module::lumen_paint::kurbo::{Affine, Rect};
use lumen_module::lumen_paint::peniko::{Color, Fill};
use lumen_module::lumen_paint::{PaintTarget, Shape};

fn paint(&self, ctx: &mut NativePaintCtx<'_>) {
    let transform = Affine::new(ctx.device_transform().coeffs);
    let Some(target) = ctx.target_as::<PaintTarget>() else { return };
    target.fill(
        Fill::NonZero,
        transform,
        Color::WHITE.into(),
        None,
        &Shape::Rect(Rect::new(0.0, 0.0, 8.0, 8.0)),
    );
}

A drawing recorded ahead of the frame goes into a lumen_paint::Recording, which the painter replays with recording.replay(&mut **target, transform).

on_click fires on every click, a double-click included

This affects scripts that define both on_click(id) and on_double_click(id) for the same element, in every script language. A quick pair of clicks used to call on_double_click alone, with neither click reaching on_click. Now both clicks reach on_click and on_double_click runs once after the second, the order a browser fires click and dblclick in. A counter, a "next" button, or a row delete driven by on_click no longer loses the second of two fast clicks.

A script that relied on the old behaviour to give one element a single-click action and a different double-click action now runs the single-click action twice before the double-click one. Make the double-click action undo or supersede what the clicks did, or move the single action to a separate control.

A row that selects on a click and opens on a double-click keeps working as written in candela:

fn on_click(id: string) { select(id); }
fn on_double_click(id: string) { open(id); }

select now runs for both clicks, which is harmless when selecting is idempotent; open still runs once.

Render backends implement one Renderer trait for windows and offscreen images

This affects Rust code that implements a render backend, calls one directly, or reads the render world's damage list: embedders, custom window backends, and tests that drive lumen-render-wgpu or lumen-render-cpu by hand.

lumen_core::traits::Renderer is now the whole renderer seam. It absorbs SurfaceRenderer and OffscreenRenderer, which are gone, and a window and an offscreen image are two kinds of FrameTarget the same renderer attaches to. SurfaceError is renamed RenderError. RenderBackend carries one constructor, renderer: fn() -> Box<dyn Renderer>, in place of surface and offscreen. lumen_core::render_backend::install_offscreen puts an attached renderer into an app and drives it each frame.

Each backend has one renderer type: WgpuSurfaceRenderer folds into WgpuRenderer and CpuSurfaceRenderer into CpuRenderer. CpuRenderer::new now builds a detached renderer; CpuRenderer::new_offscreen(width, height) builds one attached to an image. WgpuRenderer::new_offscreen returns a RenderError, and WgpuRenderer::adapter_info and WgpuRenderer::size return Option, None while detached.

The FrameDamage resource and lumen_paint::damage_union are removed, and lumen_paint::diff_retained_scenes returns whether the tree changed instead of filling a damage list.

Before:

use lumen_core::render_backend::RenderBackend;
use lumen_core::traits::{SurfaceError, SurfaceRenderer};

let renderer: Box<dyn SurfaceRenderer> = (backend.surface)();
renderer.attach(window)?;

let offscreen = (backend.offscreen)(800, 600)?;
offscreen.install(&mut app);

let mut damage = FrameDamage::default();
diff_retained_scenes(prev, curr, viewport, &mut damage);
let changed = !damage.is_empty();

After:

use lumen_core::render_backend::install_offscreen;
use lumen_core::traits::{FrameTarget, RenderError, Renderer};

let mut renderer: Box<dyn Renderer> = (backend.renderer)();
renderer.attach(FrameTarget::Window(window))?;

let mut offscreen = (backend.renderer)();
offscreen.attach(FrameTarget::Offscreen { width: 800, height: 600 })?;
install_offscreen(&mut app, offscreen);

let changed = diff_retained_scenes(prev, curr, viewport);

OS capability events are plugin events, not core message types

This affects Rust code that reads or writes the events the OS capabilities report: tray clicks, global hotkeys, notification buttons, file dialog results, clipboard reads, recent-files and autostart reads, and second launches of a single-instance app. Scripts are not affected; on_tray(id), on("hotkey", name, fn) and the other handlers fire as before.

The message types TrayClicked, HotkeyFired, HotkeyReleased, NotificationActionInvoked, FilePicked, ClipboardRead, RecentFilesRead, AutostartRead and SecondInstanceLaunched are gone from lumen_core::input, along with the re-exports lumen_os_tray::TrayClicked, lumen_os_hotkey::{HotkeyPressed, HotkeyReleased}, lumen_os_notify::NotificationActionInvoked and lumen_os_filedialog::FileDialogResult. Each capability now writes a lumen_script::PluginEvent, built by a constructor the capability exports. lumen_os_filedialog::FileDialogService::open and drain_file_dialog_results are removed; use open_single and FileDialogPlugin (or register_result_handler).

Before:

app.world.write_message(lumen_core::input::TrayClicked { id: "main".into() });

After:

app.world.write_message(lumen_os_tray::tray_click_event("main".into()));

The constructors are lumen_os_tray::tray_click_event, lumen_os_hotkey::hotkey_event, lumen_os_notify::action_event, lumen_os_lifecycle::{second_instance_event, recent_files_event, autostart_event}, lumen_script::clipboard::clipboard_read_event, and PluginEvent::from(FileDialogResultCommand). To observe these events from Rust, read MessageReader<PluginEvent> and match on the event name.

process::start takes a fourth argument, opts

This affects scripts in apps that declare lumen-process. start now takes an options value after the tag: the directory the child starts in (cwd), variables to add to its environment (env), and whether to end it when the app exits (end_at_exit). The argument is required, so a three-argument call no longer compiles in candela and raises in Rhai and Lua.

Pass the defaults to keep the previous behaviour: the child starts in the app directory, inherits the environment, and outlives the app.

candela, before and after:

process::start("git", ["status"], "git");
process::start("git", ["status"], "git", Default::default());

Rhai:

process::start("git", ["status"], "git");
process::start("git", ["status"], "git", #{});

Lua:

process.start("git", {"status"}, "git")
process.start("git", {"status"}, "git", {})

To set a field, name it and take the rest from the defaults, for example process::StartOptions { cwd: "instances/a", ..Default::default() } in candela. The options are listed with process::start in each host's scripting reference. process::stop(tag) is new and ends a child you started.

lumen_script::push_plugin_event takes the event by value

This affects Rust runtime modules that deliver events to scripts through lumen_module::lumen_script::push_plugin_event. The function now takes the PluginEvent itself instead of a reference, so a call that passes &event no longer compiles.

Before:

lumen_script::push_plugin_event(&event);

After:

lumen_script::push_plugin_event(event);

Pass a clone if you still need the event after the call.

Rebuild every .lmna artifact

The compiled-app format moved on, and the runtime refuses an artifact written by an earlier toolchain. The format of the candela bytecode an artifact carries moved as well.

This affects you if you load a precompiled app: lumenc run --artifact, a .lmna written by lumenc build, or lumen_app_new_from_lmna from the C ABI or an SDK. Loading an old one fails with an error that says:

unsupported artifact version 13 (this build reads 16)

Rebuild it with the new lumenc:

lumenc build myapp myapp.lmna

Apps you already packaged carry their own runtime and keep working as they are; they pick up the new format the next time you run lumenc package. A site from lumenc web is the same: a deployed one keeps working, and the next build writes the new format.

Rebuild portable plugins and compiler plugins

Both kinds of native plugin record the engine version they were built against, and both of those versions moved in this release.

Portable plugins (the lumen-plugin cdylibs an app lists under [dependencies], registry packages included) are checked against the script wire version. It moved because an http() request now carries a credentials option. A plugin built for the previous release fails its handshake: the app prints a banner naming the plugin and starts without it.

Compiler plugins ([[plugins]] in lumen.toml, built with lumenc-plugin) are checked against the compiled-app format, which moved as well, so lumenc refuses one built for the previous release.

Rebuild each plugin against this release's lumen-plugin or lumenc-plugin crate. If you publish one to the registry, publish the rebuilt library as a new version.

ScriptHost::call returns a CallFailure when the function raises

This affects Rust code that implements lumen_script::ScriptHost for a script language of its own, or calls call on a host directly. A failed call used to return a bare ScriptError and drop the commands the function had queued; it now returns CallFailure { error, commands }, and the runtime applies those commands the way a browser keeps what a throwing listener did.

A host implementation drains its command sink on the error path too:

fn call(&mut self, name: &str, args: &[ScriptValue]) -> Result<CallOutcome, CallFailure> {
    let result = self.run(name, args);
    let commands = self.drain_commands();
    match result {
        Ok(ret) => Ok(CallOutcome { commands, found: ret.is_some(), ret }),
        Err(error) => Err(CallFailure { error, commands }),
    }
}

A caller that only wants the error reads failure.error.

Scripts need no change. A handler that raised used to lose the text it set, the timers it armed, and the lines it printed before the error; those now apply.

The script hosts are runtime modules

The engine no longer carries a script host. Each language runs on a runtime module the toolchain ships: lumen-candela runs the bytecode a built or packaged app carries, lumen-candela-dev compiles source for lumenc run, check and build, and lumen-rhai and lumen-lua run the deprecated languages. Lumen loads the module each script needs; an app declares nothing.

This affects you in these cases.

Your app is written in Rhai or Lua. It runs as before under lumenc run and in a package, on every platform. lumenc package --static refuses it, because a static executable carries no Rhai or Lua host, and a Linux or macOS toolchain installed with --no-modules runs it without its script, with a banner that says so. candela is the supported language.

You embed Lumen from Rust. RunOptions::rhai_extensions, RunOptions::with_rhai_extension, lumen_runtime::run_with, lumenc::run_with, AppBuilder::rhai_extension and the lumenui::rhai re-export are gone. Expose a function with AppBuilder::native_fn or AppBuilder::script_fn (or RunOptions::with_native_fn / a plugin's add_script_fn), which every language reaches.

You select cargo features. The host-rhai, host-lua and host-candela features are gone from lumen, lumen-runtime, lumenui, lumen-portable, lumen-prerender, lumen-ssr and lumen-web-runtime; remove them from your manifest. The crates lumen-script-candela, lumen-script-rhai and lumen-script-lua are now lumen-candela plus lumen-candela-dev, lumen-rhai and lumen-lua. A browser assembly installs lumen_candela::CandelaPlugin itself before it hands a program to lumen_portable::hosts::install.

You built a portable plugin. The plugin wire moved to version 4: a function no longer says which languages see it (PluginFnBuilder::hosts and lumen_script::HostSet are gone; every language sees every function). Rebuild the plugin against this release; an older one is refused at load.

You load precompiled apps. The artifact format moved again: a candela program now ships as bytecode alone, with the module that runs it named. See "Rebuild every .lmna artifact".

You install with --no-modules. The script hosts are in the modules archive, so a toolchain installed without it runs and builds no script.

You use the LSP's Rhai support. lumen-lsp's lang-rhai feature is now off by default; build with --features lang-rhai to keep .rhai diagnostics.

Under navigation = "hard", page() loads a new document

This affects multi-page web apps that set [web] navigation = "hard" and move between pages from a script.

Before, hard governed links only: a script's page() call still swapped the page in place, with the app and its script state still running. Now hard applies to scripts too. page() loads the target page as its own document, as a new history entry, and page_back() and page_forward() step the browser's history. Script state does not carry from one page to the next.

Desktop apps, single-file apps, and navigation = "soft" (the default) are unchanged.

If a page relies on state a previous page set, either keep that state where the next document can read it, for example with the lumen-storage module:

[dependencies]
lumen-storage = { bundled = true }
storage::set_item("draft", text);   // before page("/next")
let draft = storage::get_item("draft");   // on the next page

or switch the site to soft navigation:

[web]
navigation = "soft"

lumenc web --serve runs the separate lumen-server binary

lumenc web --serve no longer serves the site from inside lumenc. It starts lumen-server --dev on the built site and waits for it. lumenc looks for lumen-server beside itself, then at the path in $LUMEN_SERVER, then on PATH, and fails naming those three places when none has it.

A release install (the install script, the MSI, Homebrew, Scoop, the AUR package, or the setup-lumen action) puts lumen-server beside lumenc, so nothing changes there.

A source install from cargo install lumenc does not build lumen-server. Build it from a Lumen checkout of the same version and point LUMEN_SERVER at it:

cargo build --release -p lumen-server
export LUMEN_SERVER="$PWD/target/release/lumen-server"
lumenc web myapp --serve

The --host, --port, and --allow-host flags work as before; lumenc passes them on to lumen-server.

Wheel deltas on the desktop follow the DOM sign

A desktop wheel event's delta_y used to be negative when the wheel turned toward the user, the opposite of the web target. It now follows the DOM on every target: positive delta_y scrolls down and positive delta_x scrolls right. A handler that zooms or steps through items by the wheel's sign goes the other way on the desktop until you flip its comparison.

candela, before and after:

fn on_wheel(ev: int) { if event(ev).delta_y() < 0.0 { next_page(); } }
fn on_wheel(ev: int) { if event(ev).delta_y() > 0.0 { next_page(); } }

lumenc scroll and the lumen.simulate scroll kind take the same sign, so a test that scrolled down with lumenc scroll 100 100 0 -50 now writes lumenc scroll 100 100 0 50. A Rust plugin that writes or reads lumen_core::input::MouseWheel uses the same convention.