Skip to content

Migrating to v0.0.10

A canvas draws a pixel buffer as it was at the draw call

This affects scripts and apps that draw a pixel buffer onto a <canvas> and then write to the buffer before the frame is drawn.

canvas::draw_buffer and canvas::draw_buffer_scaled now take the buffer's pixels at the call, as drawImage does on the web. Before, a draw read the buffer when the tick replayed the drawing, so a write later in the same handler or tick showed up in a draw made before it. Now it shows only once the buffer is drawn again, on every host, in a Rust app, and in a page.

A script that drew first and wrote after, expecting the write on screen, moves the draw after the write:

// before: the fill reached the screen through the draw above it
canvas::draw_buffer("art", buf, 0.0, 0.0);
canvas::buffer_fill_rect(buf, 0, 0, 8, 8, 0xff0000ff);

// after
canvas::buffer_fill_rect(buf, 0, 0, 8, 8, 0xff0000ff);
canvas::draw_buffer("art", buf, 0.0, 0.0);

A draw of a buffer handle that names no buffer now records nothing at the call, rather than being skipped when the drawing is replayed; what appears on screen is the same.

A recorded canvas fill carries its fill rule

This affects Rust code that builds or matches the canvas module's recorded calls, lumen_canvas::ops::Op, directly. Scripts, and Rust apps that draw through Context2d, are not affected.

Op::Fill is now Op::Fill(FillRule), because a fill can be nonzero or even-odd (Context2d::fill_evenodd, canvas::fill_evenodd). Write Op::Fill(FillRule::NonZero) where Op::Fill was, and match Op::Fill(_). FillRule is exported from lumen_canvas and lumenui_canvas.

ShowContextMenu is now ContextMenuEvent

This affects Rust code that reads lumen_core::input::ShowContextMenu. The message used to come only from an assistive technology's request. It is now the one context-menu request every source raises: a right click, the ContextMenu key, Shift+F10, an assistive technology, and a page's browser. It carries where a menu belongs and which button asked.

// before
fn on_menu(mut requests: MessageReader<ShowContextMenu>) {
    for request in requests.read() {
        show_menu_for(request.entity);
    }
}
// after
fn on_menu(mut requests: MessageReader<ContextMenuEvent>, mut signals: Signals) {
    for request in requests.read() {
        signals.open_menu_at("menu", request.position.x, request.position.y);
    }
}

Scripts receive the same requests as the contextmenu DOM event.

The mouse cursor comes from CSS cursor

The pointer now shows the CSS cursor of the element under it, as in a browser. Before, Lumen picked one of five cursors itself: the I-beam over text fields, a pointing hand over anything clickable (a button, a toggle, a tab, an element with a hover style or a tab-index), and grab hands over scrollbar and slider thumbs.

Text fields keep the I-beam and links show the pointing hand, from the user-agent stylesheet. Everything else shows the platform arrow, the way native buttons and web buttons do, so buttons and other clickable elements stop showing the hand. To keep it, say so in your stylesheet:

button, toggle, switch, checkbox, radio, .clickable { cursor: pointer; }

Scrollbar and slider thumbs still show grab and grabbing.

For Rust code: lumen_core::input::CursorShape is gone. CursorRequest holds a cursor (a Cursor: the url() images to try and a CursorKeyword) and the decoded image, if any, instead of a shape. A window backend of your own shows image when it is set and the keyword otherwise; CursorKeyword::None hides the pointer.

@font-face loads its font on the desktop too

A @font-face rule used to reach only the web target; a desktop run ignored it and drew with the fonts installed on the machine. The desktop now loads the rule's font file from the app directory, so text naming its family draws with it in lumenc run and in a packaged app as it does in a browser.

An app whose stylesheet already carries @font-face for its site changes look on the desktop when the rule lists a TrueType or OpenType file. A rule that lists only woff or woff2 files is left to browsers and the desktop keeps the system font; add a ttf or otf beside them to use the font on both targets.

linear-gradient angles paint the way CSS reads them

This affects desktop apps that use linear-gradient with an angle, or with no angle. The desktop painted the angle turned a quarter: 90deg ran top to bottom and the default 180deg ran right to left. It now paints what CSS says and what a browser shows: 0deg bottom to top, 90deg left to right, 180deg top to bottom.

A gradient whose angle was chosen to look right on the old desktop now turns. Write the angle CSS means:

/* before: worked around the old desktop direction */
.panel { bg: linear-gradient(90deg, #202028, #101014); }
/* after: top to bottom, on the desktop and the web alike */
.panel { bg: linear-gradient(180deg, #202028, #101014); }

opacity fades an element and its children as one picture

An element with opacity below 1 and children now composites the whole subtree first and fades the result once, as CSS does. Before, every descendant faded on its own, so a parent's background showed through its opaque children. A faded card with opaque content now looks the way it does in a browser: the content hides the card under it. Nested opacities still compound. No markup or CSS changes are needed; check any screenshot baseline that captured a faded element with children.

This affects Rust plugins that paint native leaves too. NativeExtract::place no longer takes the entity's Opacity; it reads opacity from the world itself and NativePlacement::opacity is the alpha your leaf's own paints carry. A faded ancestor with children is a layer the renderer composites over your pixels, so it is no longer part of that figure, and NativePaintCtx::opacity is 1.0.

Drop the Option<&Opacity> from the query that feeds place and the argument from the call. Before and after:

let placed = place.place(e, transform, opacity)?;
let placed = place.place(e, transform)?;

A render backend built on lumen-paint has three more Painter methods (begin_offscreen, end_offscreen, draw_offscreen), whose defaults paint every faded group directly, and paint_frame takes the backend's LayerCache as a fourth argument: pass Some from a sink that implements the three, None otherwise. Code that builds a Node::Opacity by hand sets its new key, the identity a backend keeps the group's layer under, or None.

Input messages carry a time_stamp

This affects Rust code that builds PointerData, MouseWheel, KeyPressed or KeyReleased with a struct literal, typically a test that injects input. Each now has a time_stamp: Instant field, the moment the event happened, so an app can measure how long an input took to reach the screen (see FrameTiming). PointerData::mouse stamps the current instant; a literal names one.

// before
keys.write(KeyPressed { key, modifiers, repeat: false });
// after
keys.write(KeyPressed { key, modifiers, repeat: false, time_stamp: Instant::now() });

Code that only reads these messages is unaffected.

<input value> is the text a field starts with

This affects <input> elements written with a value attribute. value used to be read as a number: a field written value="hello" failed to parse, and one written value="5" started empty on the desktop while a browser showed 5. It is now the text the field starts with, as in HTML, on every target.

<!-- before -->
<input text="hello" />
<!-- after: either works -->
<input value="hello" />
<input text="hello" />

A field that wrote both keeps the one written last.

A signal written after the bindings ran paints on the next tick

This affects plugins and Rust systems that read the PropertyStore dirty queue.

A signal written by a system the schedule ran after the bind-* readers used to be cleared from the dirty queue at the end of its tick unseen, so the screen kept the old value until some later input. Such a write now stays in the queue into the next tick, which the run loop schedules at once, and the readers apply it there. A DOM mutation queued from the C ABI or an SDK after the tick's DOM commands were collected likewise schedules the tick that applies it.

A system that gates on dirty_peek can therefore see a key on the tick after it was written as well as on the tick itself. A system that applies signal writes and skips its work when none landed reads the queue with PropertyStore::observe_dirty instead, so a write that lands after it is kept for its next run.

This affects apps that declare a <menubar> and run on Linux. It used to show nothing there, because Linux desktops have no menu bar for an app to join. The same markup now becomes a bar at the top of the window, its items reach on_menu, and their accel shortcuts work. macOS and Windows keep the OS menu bar.

An app that built its own in-window bar for Linux would now show two. Remove the hand-built one, or drop the <menubar> on Linux:

<!-- before: a hand-built bar beside the declared one -->
<menubar>...</menubar>
<row class="my-linux-menubar">...</row>
<!-- after -->
<menubar>...</menubar>

The root's top padding grows by the bar's height, so the content starts below the bar. A script reading the root's computed padding-top sees that sum.

This affects Rust SDK apps. lumenui no longer compiles the engine with your app: its build script downloads the engine's compiled crates from the Lumen release matching its version, and points the compiler at them. Three things change for an app.

Pin the toolchain. The compiled crates are readable only by the rustc that built them, so add a rust-toolchain.toml beside Cargo.toml with the channel the release names. A build with another compiler stops and prints the file to write:

[toolchain]
channel = "<the version the build names>"

Drop direct dependencies on the engine's crates. A dependency of your own on bevy_ecs, glam, or any lumen-* crate now compiles a second copy whose types do not match the engine's. Take them from lumenui instead:

// before: bevy_ecs = "0.19" in Cargo.toml, and
use bevy_ecs::prelude::Component;
// after: no extra dependency
use lumenui::prelude::*; // Component, Resource, Message, Bundle, Widget
let v = lumenui::glam::Vec2::ZERO;

Import the prelude with a glob (use lumenui::prelude::*): the bevy_ecs derives find their crate through the bevy_ecs name it brings in.

Build on the machine you target: cross-compiling a lumenui app is refused.

In a Lumen checkout the SDK's code moved to the lumenui-engine crate in sdk/rust (library lumenui_engine); sdk/lumenui is the crate apps depend on. Something that depended on the checkout's sdk/rust by path keeps its lumenui:: paths with lumenui = { package = "lumenui-engine", path = ".../sdk/rust" }, and cargo run -p lumenui --example counter is now -p lumenui-engine.

Text draws the same on the GPU and the CPU renderer

This affects apps and tools that compare screenshots of Lumen text against saved images, and Rust code that implements a lumen_paint::Painter sink.

Glyph outlines are now flattened by Lumen, once per glyph and size, and both render backends fill the same polygons. Text edges therefore shift by a fraction of a pixel's coverage compared with earlier releases, closer to the true outline than before, and the GPU and CPU renderers draw them alike. Re-capture any screenshot baselines that include text.

A custom Painter sink that should draw text the way the built-in backends do fills outline runs through lumen_paint::GlyphOutlines from its draw_glyphs, and calls GlyphOutlines::end_frame once a frame:

use lumen_paint::kurbo::Rect;
use lumen_paint::{GlyphOutlines, GlyphRun, Painter};

fn draw_glyphs(&mut self, run: &GlyphRun<'_>) {
    let mut outlines = std::mem::take(&mut self.outlines);
    let visible = Rect::new(0.0, 0.0, self.width, self.height);
    let drawn = outlines.draw(self, run, visible);
    self.outlines = outlines;
    if !drawn {
        // A colour glyph face (COLR or bitmaps): draw the run natively.
    }
}

A plugin added with add_plugin builds before the markup is parsed

This affects Rust apps that add plugins through the Rust SDK's App::add_plugin or RunOptions::with_plugin. Those plugins now build right after the app's runtime modules install, before the markup is parsed, where they used to build after it. A widget plugin's tag is therefore registered in time for the markup that uses it, with no register() call beforehand.

A plugin whose build read a resource the runtime adds while it sets up the document, such as the scripting DOM's command queue, finds it missing now. Read it from a system the plugin adds instead: systems run once the app is built, when everything the runtime installs is there.

Pointer events carry the device

Pointer events now report the pen and touch readings the web PointerEvent has, and a node can capture a pointer. Three existing behaviours change with it.

A pointermove reports button as -1, as on the web; it used to report 0. A handler that tells a drag from a hover by button() == 0 reads buttons() instead, which holds the mask of buttons down.

// before
if e.button() == 0 { drag(e); }
// after
if e.buttons() & 1 != 0 { drag(e); }

The back and forward mouse buttons report 3 and 4; they used to report 0, the primary button's code.

Rust code that writes PointerMoved, PointerPressed or PointerReleased itself (a window backend, a test) fills in the new pointer field with the device behind the event. PointerData::mouse(buttons) is the mouse, and Default::default() the mouse with no button held. Code that reads presses to drive a widget checks is_primary() rather than the button alone, so a second finger does not press it.

// before
PointerPressed { position, button, local: None }
// after
PointerPressed { position, button, local: None, pointer: PointerData::mouse(1) }

The C ABI is 0.15: LumenEvent gains the pointer fields at its end and the lumen_node_*_pointer_capture calls are new. The existing fields keep their offsets, so a callback built against 0.14 reads them unchanged.

Rebuild every .lmna artifact and every compiler plugin

The compiled-app format moved on: a tag's defaults now travel as rules in the artifact's stylesheet rather than as values in its tree, an element with a custom widget tag keeps the attributes it was written with, an element carries its CSS image-rendering, cursor and mask-* properties, and an <input> keeps the type and alpha it was written with. The runtime refuses an artifact written by an earlier toolchain, which would lose those defaults the first time an element restyled and hand its widgets no attributes.

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), or if your app declares compiler plugins under [[plugins]]. Loading an old artifact fails with an error that says:

unsupported artifact version 16 (this build reads 20)

Rebuild the artifact with the new lumenc:

lumenc build myapp myapp.lmna

A compiler plugin records the same format version, so lumenc refuses one built for the previous release; rebuild it against this release's lumenc-plugin crate. A plugin that adds elements in transform_ir gives them their styles with lumen_ir::css::apply_markup_declaration; see the plugin guide.

Apps you already packaged carry their own runtime and keep working as they are, as does a deployed site from lumenc web.

A restyle resolves an element from scratch, and tag defaults are rules

This affects apps that change classes, ids or inline styles from a script, apps that create elements from a script, and Rust code that drives an element's style components.

A restyle (a class added or removed, an id changed, a set_style, a theme flip, a breakpoint crossing) used to copy over only the properties the new resolution set, so a property that only a removed class had set stayed on the element. It now resolves the element from scratch, as the first cascade did: a property no rule, markup attribute or inline style sets any more goes back to its initial value. Removing .selected from a row takes its justify and its background with it.

What a tag is by default (a <column> stacking downwards, an <overlay> covering its parent, a <spacer> taking the space left, a <scroll> scrolling vertically) now comes from the built-in user-agent sheet instead of from values the parser wrote into the element. An element a script creates takes those defaults too: spawn("column") used to lay its children out in a row, and now stacks them. A stylesheet rule for one of these tags applies from the first frame on, where it used to take effect only once the app had restyled. A scroll="x" attribute now lays its element's children out in a row whatever the stylesheet says about flex-direction, like any other attribute.

The metrics of the parts <tabs>, <menu>, <date-picker> and <time-picker> expand into (a tab button's height and padding, a menu row's height, a picker's width) moved into the same sheet. A skin's or an app's rule for .tab-btn, .menu-item, .date-picker and the rest outranks them, as it already did once the app had run its first frame.

Rust

A value a system writes into Style, Visuals, TextStyle or Opacity behind the cascade's back is lost at the next restyle. Hold it in lumen_core::style_overrides::StyleOverrides instead; see "Holding a style value" in the plugin guide.

The snapshot components the built-in widgets kept are gone, because the layer replaces them: HoverBaseColor, PressBaseColor, BaseBorder, StateStyleBase, CheckboxBaseFill and RadioBaseFill, the default_top, default_bottom and positioned fields of PopupPanel, and the saved_display field of IfMarker. Read what an element shows under a widget's layer with StyleOverrides::below.

In lumen_ir::css, reapply_single, reapply_single_with_media and reapply_with_ancestors are replaced by RestyleWalk for walking a tree, resolve_element for one element, and reapply_detached for an element not yet in the tree. AncestorInfo carries an element's own declarations, sibling list and content.

Before:

use lumen_ir::css::{AncestorInfo, SiblingPosition, reapply_with_ancestors};

let mut el = Element { tag: "tile".into(), ..Default::default() };
el.attrs.classes = vec!["card".into()];
reapply_with_ancestors(&mut el, &sheet, &media, &ancestors, SiblingPosition::default())?;
let attrs = el.attrs;

After:

use lumen_ir::css::{AncestorInfo, resolve_element};

let node = AncestorInfo::new("tile", vec!["card".into()], None)
    .with_declarations(markup_and_inline_declarations);
let attrs = resolve_element(&node, &sheet, &media, &ancestors);

A packaged Rust app is one executable with the engine linked in

This affects Rust SDK apps. lumenc run, lumenc build and lumenc package now build a Rust app with plain cargo, with no -C prefer-dynamic added to RUSTFLAGS, so the engine is linked into the executable on every platform, as the published lumenui already links it. lumenc package copies that executable and the app's files; liblumen_engine and the Rust standard library no longer travel beside it, and a package script that expected them should stop looking for them.

A Rust app that declares a runtime module under [dependencies] in lumen.toml is now refused by lumenc package on every platform, not only Windows: a runtime module loads only into a shared engine, and a Rust app has none. Portable plugins still travel.

lumen_source!("src/main.lmn", "src/main.css") now loads the app at the directory above src/, so lumen.toml beside src/ is read in a debug build. Before, a markup file under src/ made src/ itself the app directory, and the run stopped with the "found code files at the app root" error. Keep markup and stylesheet under src/ and point the macro there:

// before: markup beside the code, which the src/ layout rejects
lumen_source!("ui/main.lmn", "ui/main.css")
// after
lumen_source!("src/main.lmn", "src/main.css")

A release Rust app reads its files from the app, not the working directory

This affects Rust SDK apps built in release, and code that builds or matches Source::Embedded by hand.

A release build embeds the entry markup and stylesheet, and reads everything else (@imported stylesheets, @font-face fonts, url() images, lumen.toml) from the app directory. That directory used to be the working directory, so a release binary started anywhere but its crate directory lost its imports, fonts, images and window settings. It is now the executable's directory when that holds a lumen.toml (a packaged app), else the directory lumen_source! embedded the sources from, and the working directory only when neither exists.

A release binary that relied on being started from a directory holding a different lumen.toml than its own now reads its own; pass that directory with with_dir instead.

Source::Embedded has a third field, dir, the app directory the sources came from. Source::embedded(markup, css) leaves it empty, and the new Source::embedded_from(markup_file, markup, css) records the app directory of markup_file. Code that matches the variant adds .. or the field:

// before
Source::Embedded { markup, css } => { /* ... */ }

// after
Source::Embedded { markup, css, .. } => { /* ... */ }

The Scoop bucket moved to the Lumen repository

This affects Windows users who installed Lumen with Scoop from the lumen-fx/scoop-lumen bucket. The bucket is now the Lumen repository itself, and new releases reach Scoop only there. The old bucket stays at the last release it carried, so scoop update lumen stops finding anything newer.

Replace the bucket once, then update as before:

scoop bucket rm lumen
scoop bucket add lumen https://github.com/lumen-fx/lumen
scoop update lumen

Lumen itself stays installed while the bucket is replaced, and the app name is still lumen, so nothing else changes.

<split> is a built-in tag

This affects apps and runtime modules that use a tag named split of their own: a custom widget registered as split, or a module that lists split under tags. <split> is now the built-in container of resizable panes, so a module declaring it is refused when it loads, as it is for every other built-in tag, and markup that writes <split> gets the built-in panes and gutters.

Rename your tag:

<!-- before -->
<split>...</split>
<!-- after -->
<my-split>...</my-split>

Or drop it and use the built-in one, which puts a draggable gutter between its children; see <split> in the tags reference.

The tooltip popup takes the app's stylesheet

This affects desktop apps with a .tooltip rule or a tooltip type selector, and apps whose root sets a font or a text color.

The popup a <tooltip> opens used to be styled by the runtime alone, so a .tooltip rule in an app's stylesheet never reached it. It is now a tooltip element with the class tooltip under the root, and the cascade styles it like any element: an app's .tooltip rule restyles it over the skin's look, and var() tokens declared on the root resolve in it. A rule written for tooltips that did nothing before now applies.

The popup inherits from the root, so a font family set on the root reaches tooltip text. Its default size stays 13 px, and its unskinned look (a dark panel with light text) now comes from the built-in user-agent sheet, which any skin or app rule for .tooltip outranks.

Rust

lumen_primitives::TooltipPopup entities now carry LumenTag, DomHidden and a StyleOverrides that holds their position and inset, and are children of the root of the trigger's tree. The runtime no longer inserts a fallback Visuals and TextStyle on them. Code that wrote a popup's Style, Visuals or TextStyle directly loses those writes at the next restyle; hold the value in StyleOverrides instead.

Widget::spawn makes a given entity the widget

This affects Rust code that calls Widget::spawn itself or implements the Widget trait by hand. An element written in markup with a #[derive(Widget)] tag now becomes that widget: the spawner hands the element and its attributes to Widget::spawn, which inserts the component on it. So spawn no longer spawns an entity of its own; it takes the entity to make the widget and returns nothing.

// before
let gauge = Gauge::spawn(parent, &attrs, world);
// after
let gauge = world.spawn_empty().id();
Gauge::spawn(gauge, &attrs, world);

A hand-written impl Widget changes its spawn the same way: insert the component on the entity it is given.

// before
fn spawn(_parent: Entity, attrs: &Attributes, world: &mut World) -> Entity {
    world.spawn(Gauge::from(attrs)).id()
}
// after
fn spawn(entity: Entity, attrs: &Attributes, world: &mut World) {
    world.entity_mut(entity).insert(Gauge::from(attrs));
}

A hand-written impl Widget registered with register_widget_tag still only makes its tag parse. Register it with lumen_widget::register_widget::<Gauge>() for its elements to become the widget, as the derive's register now does.