Skip to content

LVGL9 XML Guide

Comprehensive guide to the declarative XML UI system with reactive data binding, based on practical experience building the HelixScreen UI. The XML engine lives in lib/helix-xml/ — a permanent MIT-licensed fork of LVGL’s XML engine taken at a15dcbeb5 (v9.4.0-358), the last commit before v9.5 removed XML from core. It has no upstream; see HELIX_XML_FORK.md.

Last Updated: 2026-07-15


  1. Overview & Architecture
  2. Project Structure
  3. Core Concepts
  4. Layouts & Positioning
  5. Common UI Patterns
  6. Responsive Design
  7. Styles & Theming
  8. Event Handling
  9. Implementation Guide
  10. Best Practices
  11. Troubleshooting

LVGL 9’s XML system enables declarative UI development with reactive data binding through the Subject-Observer pattern. This separates UI layout (XML) from business logic (C++), similar to React or Vue.

┌─────────────────┐
│ XML Component │ ← Declarative UI layout
│ (home_panel) │
└────────┬────────┘
│ bind_text="subject_name"
↓
┌─────────────────┐
│ Subjects │ ← Reactive data (strings, ints, colors)
│ (status_text) │
└────────┬────────┘
│ lv_subject_copy_string()
↓
┌─────────────────┐
│ C++ Wrapper │ ← Business logic & state updates
│ (ui_panel_*.cpp)│
└─────────────────┘

ALL UI updates MUST use reactive data binding. Direct widget manipulation is an anti-pattern.

<!-- ✅ CORRECT - Reactive binding in XML -->
<lv_label bind_text="status_message"/>
<lv_button>
<bind_flag_if_eq subject="connection_ready" flag="clickable" ref_value="1"/>
</lv_button>
// ✅ CORRECT - Update subjects in C++
lv_subject_set_string(&status_message, "Connected");
lv_subject_set_int(&connection_ready, 1);
// UI updates automatically

helixscreen/
├── ui_xml/ # 60+ XML component definitions
│ ├── globals.xml # Theme constants, responsive tokens
│ ├── app_layout.xml # Root: navbar + content area
│ ├── navigation_bar.xml # Vertical nav buttons
│ ├── *_panel.xml # Main panels (home, controls, motion, etc.)
│ ├── *_overlay.xml # Modal overlays
│ ├── *_modal.xml # Dialog modals
│ └── icon.xml # Icon custom widget
│ # (text_heading/text_body/text_small/spinner are
│ # C++-registered widgets, not XML files — see below)
├── src/
│ ├── main.cpp # Entry point, initialization
│ ├── xml_registration.cpp # Component registration
│ └── ui/
│ ├── theme_manager.cpp # Responsive token + theme registration
│ ├── ui_text.cpp # text_heading/text_body/text_small widgets
│ ├── ui_spinner.cpp # spinner widget
│ ├── ui_nav_manager.cpp # Navigation system
│ └── ui_panel_*.cpp # Panel logic with subjects
├── include/
│ ├── ui_icon_codepoints.h # MDI icon definitions
│ └── ui_*.h # Panel headers
├── assets/
│ ├── fonts/ # MDI icon fonts, Montserrat
│ └── images/ # UI images
└── docs/devel/
├── LVGL9_XML_GUIDE.md # This file
└── LVGL9_XML_ATTRIBUTES_REFERENCE.md # Quick-lookup cheatsheet

Registration Flow (main.cpp + xml_registration.cpp)

Section titled “Registration Flow (main.cpp + xml_registration.cpp)”
// 1. Register fonts
lv_xml_register_font(NULL, "montserrat_16", &lv_font_montserrat_16);
lv_xml_register_font(NULL, "montserrat_20", &lv_font_montserrat_20);
// 2. Register globals FIRST (constants must be available)
lv_xml_register_component_from_file("A:ui_xml/globals.xml");
// 3. Register responsive spacing tokens (both take the lv_display_t*)
theme_manager_register_responsive_spacing(display); // Sets #space_md, #space_lg, etc.
theme_manager_register_responsive_fonts(display); // Sets #font_body, etc.
// 4. Components register on first use: when a tag, extends= base or
// lv_xml_create() name is not yet known, the loader installed by
// helix::register_xml_on_first_use() registers ui_xml/<name>.xml or
// ui_xml/components/<name>.xml. No list, no ordering between components.

Only styles.xml registers eagerly (its theme-token consts resolve at registration), plus color_picker and ams_edit_overlay, whose scopes C++ fills with responsive consts. A name with no XML file makes lv_xml_create() return NULL and log an error.


Engine semantics live in the engine repo. Subjects, every binding element, the expression language, <if>/<repeat>, and the two silent-failure rules (cond= vs <subject_expr> resolution order; flag binds being two-way) are documented — and pinned by tests — in lib/helix-xml/docs/BINDINGS.md. Read that for what a construct does.

What follows is HelixScreen’s use of it: our widgets (ui_button, ui_card, text_*), our design tokens, our ui_breakpoint tiers, and the conventions that only make sense against this codebase.

Components are reusable UI pieces defined with the <component> tag.

<component>
<!-- Optional: Component API (properties from parent) -->
<api>
<prop name="text" type="string" default="Click me"/>
<prop name="enabled" type="bool" default="true"/>
</api>
<!-- Optional: Local constants -->
<consts>
<px name="button_size" value="36"/>
</consts>
<!-- Optional: Local styles (NO style_ prefix!) -->
<styles>
<style name="style_base" bg_color="0x333" text_color="0xfff"/>
</styles>
<!-- The actual UI definition -->
<view extends="lv_button" width="#button_size">
<!-- Use API props with $ prefix -->
<lv_label text="$text" align="center"/>
<style name="style_base"/>
</view>
</component>
Type Description Example
string Text values default="Hello"
int Integer numbers default="42"
bool true/false default="true"
color Hex colors default="0xff4444"
subject Subject references For data binding

A component can expose a named widget as a slot for the instantiating screen to fill. Nest a <component_name-slot_name> tag inside the instantiation; the engine splits the tag at the first -, requires the prefix to be a registered component, and re-parents the tag’s children into the widget named slot_name inside that instance:

<header_bar title="Motion">
<header_bar-header_content>
<lv_label name="my_readout" bind_text="some_subject"/>
</header_bar-header_content>
</header_bar>

header_bar does this with its header_content strip (between the title region and the action buttons). A slot widget should be width="content" and not clickable, so an unfilled slot costs its container no layout and steals no taps.

Subjects are observable data containers that automatically update bound widgets. Declaring them in XML — the <subjects> block, the int/string/float/color types, and component vs. global scope — is engine behaviour: BINDINGS.md § Subjects. What follows is the C++ side: how HelixScreen creates and registers them.

lv_subject_init_string() // String data (text labels)
lv_subject_init_int() // Integer data (sliders, counters)
lv_subject_init_pointer() // Pointer data (custom objects)
lv_subject_init_color() // Color data (dynamic theming)
// 1. Create subject in C++
static lv_subject_t status_subject;
static char status_buffer[128];
// 2. Initialize with default value
lv_subject_init_string(&status_subject, status_buffer, NULL,
sizeof(status_buffer), "Initial status");
// 3. Register globally (BEFORE creating XML)
lv_xml_register_subject(NULL, "status_text", &status_subject);
// 4. Create XML (widgets automatically bind)
lv_obj_t* panel = lv_xml_create(parent, "home_panel", nullptr);
// 5. Update subject (all bound widgets update automatically)
lv_subject_copy_string(&status_subject, "New status");

CRITICAL: Register subjects BEFORE creating XML components that bind to them.

A string subject does not allocate: the buffer handed to lv_subject_init_string() is the storage and must outlive every binding that reads it, so a stack buffer is a use-after-free waiting to happen. Use a static or heap buffer — BINDINGS.md § String subjects need a caller-owned buffer.

LVGL XML uses prefix sigils to distinguish different value types:

Sigil Meaning Example Context
# Design token / const style_pad_all="#space_md" Spacing, colors, sizes
$ Component prop text="$primary_text" Inside component templates
$i <repeat> loop index text="$i" Inside a <repeat> body (see Repeating fragments)
${expr} Embedded composition / integer expression bind_text="slot_${i + 1}_label", style_translate_x="${i * 84}" Splices a bare name (${i}, ${grp}) or evaluates an integer expression and splices the result. See Repeating fragments
@ Subject binding text="@my_subject" Reactive data on ui_button

A value that starts with # is always read as a const, including a value passed through a component prop. An unknown name resolves to nothing, so a user or plugin string that happens to begin with # (a device name, a macro, a plugin widget title) passed as a prop renders as an empty label. Set such text from C++ instead (helix::ui::set_row_label_text, include/ui_row_text.h).

Colors and theme switches. helix-xml records every inline style_* color the XML sets. On a live dark/light or theme switch:

Written as Follows the switch Kept from the live recolor pass
Global token, inline (style_text_color="#text_muted") yes, re-resolved by name yes
Global token in a named <style> yes yes (a bound or component style beats the pass’s text color)
Literal (style_bg_color="0x000000") no, it is fixed by design yes
$prop value, or a component-local <const> no yes
Nothing set (theme default) via the shared theme styles and the recolor pass no

A color C++ changed after creation is left alone by the re-apply. Details: THEME_SYSTEM.md § Authored Inline Colors.

The @ prefix on ui_button’s text attribute marks a value as a subject reference (reactive) vs. a literal string (static). Alternatively, bind_text always treats its value as a subject name (no @ needed). See ui_button for details.

<!-- Bind label text to string subject -->
<lv_label bind_text="status_text"/>
<!-- Bind with format string -->
<lv_label bind_text="temp_value" bind_text-fmt="%.1f°C"/>
<!-- Bind slider value to integer subject -->
<lv_slider bind_value="volume" range="0 100"/>

Reactive color: there is no bind_style_<prop> attribute handler. To make a color react to a subject, define two <style>s and swap them with a bind_style_if_eq / bind_style_if on the widget (see the “Reactive styles” section below), or drive the color through a themed token.

Note: Standard LVGL widgets (lv_label, lv_slider) resolve bind_text directly as a subject name. The @ prefix convention is specific to ui_button, which needs to disambiguate between literal button labels and subject references.

Widgets that understand text= also accept inline element content, HTML-style:

<text_muted>Print speed</text_muted>
<!-- equivalent to: -->
<text_muted text="Print speed"/>

Inline text is translatable by default. The literal string is used as the translation key (and as the fallback when no translation exists), and the label re-resolves on language change – same behavior as the label=/label_tag= pairs on setting rows. make translation-sync extracts inline text automatically. A literal text= is translatable the same way (its value is the implied tag); translation_tag="" keeps a string untranslated.

Rules:

  • Attributes win. If the element also has text=, bind_text=, or translation_tag=, the inline text is dropped with a runtime warning.
  • Whitespace collapses HTML-style. Leading/trailing whitespace is trimmed and internal runs (including newlines – even explicit &#10;) collapse to a single space. For multi-line label text, use text="Line1&#10;Line2".
  • $prop / #const resolve exactly like attribute values (whole-value): <text_muted>$title</text_muted> works inside a component view.
  • Literal text starting with $ or # is not supported. It’s parsed as a prop/const reference; if the name doesn’t resolve, the text is dropped with a warning instead of rendering literally. Use text="$5.00" for a literal string that happens to start with one of these sigils.
  • Mixed content is allowed: text before/after child elements applies to the containing element; children are unaffected.
  • Widgets that don’t understand text ignore inline content silently, like any unknown attribute.
  • Inline text on the root <view> element of a component is not supported – it’s silently dropped. Use text=/bind_text= on the view’s opening tag, or put the inline text on a child element instead.
<lv_obj>
<!-- Hide when current_step == 1 -->
<bind_flag_if_eq subject="current_step" flag="hidden" ref_value="1"/>
<!-- Disable when level >= 100 -->
<bind_flag_if_ge subject="level" flag="disabled" ref_value="100"/>
</lv_obj>

Six comparison suffixes exist — _eq, _not_eq (spelled out; there is no _ne), _gt, _ge, _lt, _le — plus the expression form bind_flag_if cond="...". What they do, and the two-way rule that makes a non-matching bind actively remove the flag rather than abstain, are in BINDINGS.md § Binding elements.

Supported Flags: hidden, clickable, checkable, scrollable, disabled, ignore_layout, floating

bind_state_if_eq and its five comparison siblings (plus the cond= form) toggle an lv_state_t instead of a flag — same shape, same two-way behaviour, see BINDINGS.md § Binding elements.

<lv_button>
<!-- Disable when WiFi is off -->
<bind_state_if_eq subject="wifi_enabled" state="disabled" ref_value="0"/>
</lv_button>

Difference: Flags control behavior; States control visual appearance.

Apply entire style objects conditionally:

<styles>
<style name="temp_normal" text_color="0xffffff"/>
<style name="temp_warning" text_color="0xffaa00"/>
<style name="temp_critical" text_color="0xff0000"/>
</styles>
<lv_label bind_text="temperature">
<bind_style name="temp_normal" subject="temp_state" ref_value="0"/>
<bind_style name="temp_warning" subject="temp_state" ref_value="1"/>
<bind_style name="temp_critical" subject="temp_state" ref_value="2"/>
</lv_label>

⚠️ CRITICAL: Style Priority

Inline style attributes (e.g., style_bg_color="#card_bg") have higher priority than bind_style in LVGL’s style cascade. If you set an inline style on an element, bind_style cannot override that property.

<!-- ❌ WRONG - inline bg_color will override bind_style -->
<lv_button style_bg_color="#card_bg">
<bind_style name="active_style" subject="is_active" ref_value="1"/>
</lv_button>
<!-- ✅ CORRECT - use TWO bind_styles, no inline bg_color -->
<lv_button>
<bind_style name="inactive_style" subject="is_active" ref_value="0"/>
<bind_style name="active_style" subject="is_active" ref_value="1"/>
</lv_button>

Semantic text widgets (text_*) sit one notch below binds. Their built-in font and color are applied as a shared style at create time, before nested bind elements are parsed — so a bind_style_if_* carrying text_font (or text_color) can retier a text_small to another font, and an inline style_text_font attribute still beats both, exactly like the bg_color rule above. This is the supported way to swap a text widget’s font from a width-band or mode subject without touching C++. An <icon>’s face and temp_display’s four labels sit in the same notch (helix::ui::apply_font_style): a bound style can retier them too. The home tiles scale their glyph, value and label through <bind_tile_rung ladder="icon|value|label" subject="..."/> (include/ui_tile_rung.h), which applies the face its rung names through the same shared style.

Rule: When using bind_style for reactive visual changes, do NOT set inline style attributes for the properties you want to change reactively.

⚠️ Moving flex_flow into a style? Set layout="flex" there too.

The inline flex_flow="column" attribute sets two things — LV_STYLE_FLEX_FLOW and LV_STYLE_LAYOUT. A <style> sets only the property you name. So obeying the rule above and lifting flex_flow out of the element into two bound styles silently removes the layout, and a flex container with a flow but no layout runs no layout at all: every child lands stacked on the container’s origin, same x, same y. Nothing warns.

<!-- ❌ WRONG - children all pile up at the origin -->
<style name="list_micro" flex_flow="row_wrap"/>
<style name="list_wide" flex_flow="column"/>
<!-- ✅ CORRECT -->
<style name="list_micro" layout="flex" flex_flow="row_wrap"/>
<style name="list_wide" layout="flex" flex_flow="column"/>

Symptom to recognise: helix-screen ctl geom <container> 3 reports every child at identical x/y, and the container’s height collapses to one row. See ui_xml/ams_environment_overlay.xml for a worked example (#1192).

Applying One Style to Multiple Parts (parts=...)

Section titled “Applying One Style to Multiple Parts (parts=...)”

bind_style and every bind_style_if_* variant accept parts="main,indicator" — one element applying the style to several widget parts instead of one binding per part (BINDINGS.md § Styles across parts). Use it when parts must share a reactive style: arc background+indicator at the same stroke width, slider track+indicator+knob at the same color. The part names are:

Part name LV_PART_*
main LV_PART_MAIN
scrollbar LV_PART_SCROLLBAR
indicator LV_PART_INDICATOR
knob LV_PART_KNOB
selected LV_PART_SELECTED
items LV_PART_ITEMS
cursor LV_PART_CURSOR
<styles>
<style name="arc_w_8" arc_width="8"/>
</styles>
<!-- One line applies arc_w_8 to LV_PART_MAIN AND LV_PART_INDICATOR. -->
<lv_arc>
<bind_style name="arc_w_8" parts="main,indicator"
subject="arc_thickness_tier" ref_value="2"/>
</lv_arc>

State bits from selector (if present) are preserved across each part — parts="main,indicator" selector="pressed" applies the style to both parts in the pressed state.

Without parts, the existing selector="indicator" form (single part + optional state) still works. parts is an opt-in extension for the multi-part case; it’s helix-xml’s extension over upstream LVGL XML.

Max 8 parts per attribute (more than enough — even sliders only have 3-4).

Conditional Style Bindings with Comparison Operators

Section titled “Conditional Style Bindings with Comparison Operators”

bind_style_if_* applies a style only when the subject matches a comparison, using the same six suffixes as the flag binds (_eq, _not_eq, _gt, _ge, _lt, _le — see BINDINGS.md § Binding elements), where plain bind_style only does exact match. Attributes are the same as bind_style: name (style name), subject (subject name), ref_value (comparison value), and optional selector (part+state selector) or parts (comma-list of parts — see “Applying One Style to Multiple Parts” above).

<styles>
<style name="pad_micro" pad_left="8" pad_right="8"/>
<style name="pad_standard" pad_left="16" pad_right="16"/>
</styles>
<lv_obj>
<!-- Compact padding on Micro breakpoint (index 0) -->
<bind_style_if_eq name="pad_micro" subject="ui_breakpoint" ref_value="0"/>
<!-- Standard padding on Tiny and above (index >= 1) -->
<bind_style_if_ge name="pad_standard" subject="ui_breakpoint" ref_value="1"/>
</lv_obj>

Why use bind_style_if_* instead of bind_style? The bind_style element only matches exact values, so you need one bind_style per possible value. With bind_style_if_ge, a single element covers all breakpoints above a threshold. This is essential for responsive styling where you have 7 breakpoint tiers.

CRITICAL: Remove inline styles when using bind_style_if_*. The same priority rule applies as with bind_style – inline style_* attributes always win over added styles. When switching padding responsively, do NOT set style_pad_left on the element; use two bind_style_if_* elements instead.

Every bind_flag_if_* / bind_state_if_* / bind_style_if_* variant above compares one subject against one ref_value. When a condition needs to combine several subjects (error_flag OR temp > threshold) or do arithmetic, use the expression form — cond="..." on bind_flag_if / bind_state_if / bind_style_if, or a <subject_expr name="X" expr="..."/> derived subject inside a component’s <subjects> block — rather than stacking single-subject binds or hand-rolling a C++ derived subject.

The grammar (integer-only, nonzero is truthy) and the <subject_expr> element are documented in BINDINGS.md § The expression language and § <subject_expr>. Two points worth repeating here: use the word forms (and/or/not/gt/lt/…) because && and < need XML entity escaping, and every operand of a <subject_expr> must already be declared when the component is registered — globally, or earlier in the same <subjects> block. Division and modulo by zero evaluate to 0 instead of crashing.

Which phase registers the subject decides which construct you can use
Section titled “Which phase registers the subject decides which construct you can use”

<subject_expr> resolves its operands when the component is registered (Phase 8c, register_xml_components()); cond= resolves when the view is created. So an expression over a subject that some init_subjects() creates at Phase 9a compiles only in the cond= form — as a <subject_expr> it silently never registers.

Full rule, both directions, and the tests that pin it: lib/helix-xml/docs/BINDINGS.md § When each is resolved. HelixScreen phases are in architecture/01-declarative-ui.md. Worked examples in this tree: ui_xml/temp_graph_overlay.xml (temp_graph_mode).

There is a third way out when the same condition is needed on several surfaces and an operand comes from a Phase 9a init_subjects(): publish the answer as a plain subject from C++ and let every site bind that one name — a subject="..." reference resolves at view-create time, so the phase trap does not apply. That is what the Z-offset save affordance’s z_offset_save_available subject does (include/z_offset_utils.h#init_save_available_subject). Reach for it only when repeating the cond= would put one rule in several files; a condition used once belongs inline.

<!-- flag: show the block only while the alarm is up (invert reads as "show when") -->
<lv_obj>
<bind_flag_if cond="demo_error or demo_temp gt demo_threshold" flag="hidden" invert="true"/>
<text_heading text="ALARM"/>
</lv_obj>
<!-- state: disable a ui_button -->
<ui_button text="Action">
<bind_state_if cond="demo_alarm" state="disabled"/>
</ui_button>
<!-- style: swap a whole style on a ui_card (name/selector/parts as in bind_style_if_eq) -->
<styles>
<style name="demo_alarm_style" bg_color="#warning" bg_opa="255"/>
</styles>
<ui_card>
<bind_style_if name="demo_alarm_style" cond="demo_alarm"/>
<text_body text="Styled by bind_style_if"/>
</ui_card>

These hide an element from a component prop rather than a subject, resolved once at parse time and never reasserted afterwards — see BINDINGS.md § Prop-driven visibility for when to prefer them over a subject bind. The reference value is pipe-delimited:

Attribute Behavior
hidden_if_empty="$prop" Hides the element if the resolved prop value is an empty string
hidden_if_prop_eq="$prop|ref_value" Hides the element if the resolved prop equals ref_value (pipe-delimited)
hidden_if_prop_not_eq="$prop|ref_value" Hides the element if the resolved prop does NOT equal ref_value
<api>
<prop name="description" type="string" default=""/>
<prop name="mode" type="string" default="basic"/>
</api>
<!-- Hidden when no description is provided -->
<icon src="info_outline" hidden_if_empty="$description"/>
<!-- Hidden when mode is "advanced" -->
<lv_obj hidden_if_prop_eq="$mode|advanced">
<text_body text="Basic mode content"/>
</lv_obj>

For visibility that must respond to subject changes at runtime, use bind_flag_if_* instead.

❌ No bind_text_if_eq - use multiple labels with bind_flag_if_* for conditional text.

✅ Compound conditions are supported via the expression evaluator (see “Expression Conditionals” above) — cond="a or b gt c" on bind_flag_if/bind_state_if/bind_style_if, or a <subject_expr> derived subject for a condition reused in multiple places. This replaces stacking several single-subject bind_flag_if_* elements or writing a hand-rolled C++ derived subject for “OR of two subjects” type logic.

Reuse alone does not pick <subject_expr> — check the phase table above first. If any referenced subject comes from an init_subjects() (Phase 9a), the derived subject silently never registers, so either repeat the cond= at each site or, when that would scatter one rule across several files, publish the answer as a C++ subject every site binds (see “Which phase registers the subject” above).

✅ Several bindings on one flag or state OR together. Give each independent reason its own line — a widget disabled while a job holds the machine or while an operation is running gets two bind_state_if_eq elements, and the state is applied while either holds, in whatever order the subjects notify. What that does not give you is the conjunction: two bindings never AND, so a condition that needs one is a single expression (cond="can and dirty"). Composition is per widget and per property; it never reaches an ancestor’s binding on the same flag. Worked example and the tests that pin it: lib/helix-xml/docs/BINDINGS.md § Several bindings on one property OR together.

<repeat count="N">...body...</repeat> expands its body N times, so a fixed-size list of widgets becomes XML structure instead of a C++ create-and-wire loop; $i is the zero-based index, and a count naming a subject re-expands reactively. See BINDINGS.md § <repeat> for the base semantics; the rest of this section is the detail that bites in practice.

count accepts three forms:

Form Example Meaning
Literal count="4" A fixed integer (clamped to [0, 256]), resolved once at load time. The expansion never changes.
#const count="#rows" A component <const> value, resolved once at load time.
Subject name count="row_count" Reactive. Expands to the subject’s current value at load time, then re-expands automatically every time the subject changes — teardown of the old items and creation of the new ones happens on an async, off-tree-reparent path (no synchronous deletion inside the observer callback).

Each iteration re-resolves the body against pristine attribute values, so $i, $param, and #const references all yield independent per-iteration results — each iteration gets its own index, not a shared last value.

⚠️ Subject-bound <repeat> MUST be the last child of its parent, or the only child of a dedicated container. On rebuild, the old expansion’s roots are detached and the new ones are created fresh — and LVGL always appends a freshly-created child to the end of its parent’s child list. If a subject-bound <repeat> shares a parent with static siblings that come after it in the document, those siblings stay put but the rebuilt repeat items land after them, silently reordering the layout every time the count changes. A literal or #const count never rebuilds, so this only matters for subject-bound count. Fix: give the <repeat> its own container (an <lv_obj> wrapper with no other children), or make it the last element inside its parent.

The bare $i sigil is a whole-value substitution: text="$i" becomes the index, but text="slot_$i" does not splice. To compose the index (or a component prop) into a larger string, use the embedded ${name} sigil. This is what lets a repeated widget bind to its own per-iteration subject:

<lv_obj name="root">
<repeat count="3">
<lv_label name="lbl" bind_text="demo_${i}_v"/>
</repeat>
</lv_obj>
<!-- three labels bind to subjects demo_0_v, demo_1_v, demo_2_v -->

${i} resolves to the loop index; any other ${name} resolves against the component’s props (passed attributes first, then the <prop> default). Both can appear in the same value, so a component with <prop name="grp"/> instantiated as <my_row grp="fan"/> can bind bind_text="status_${grp}_${i}_x" → status_fan_0_x, status_fan_1_x, … The C++ side is responsible for registering those indexed subjects; an unresolved ${name} splices empty and logs a warning.

${…} also evaluates integer expressions and splices the result as text: ${i + 1} (1-based names), ${i * 84} (computed numeric attributes like style_translate_x), ${base * scale} (subject operands), ${cols * 2} (a numeric component prop). A single bare name (${i}, ${grp}) still means name-substitution; a token containing operators is evaluated. Operands: the loop index i, integer literals, numeric props, and subjects; the grammar and word forms are the same as expression conditionals. Division/modulo by zero and any unresolvable or malformed expression splice empty and log a warning.

⚠️ Resolve-once. A ${expr} is evaluated once, when the widget is created — subject operands are read at that moment and the composed value does not update if the subject changes later. A <repeat count="subject"> rebuild re-runs composition; a standalone attribute does not. For a value that must track a subject live, use a bind_* binding, not composition.

<repeat> is intercepted directly by the XML view parser (it creates no widget of its own), so its body must be well-formed markup that would be valid where the <repeat> sits. Nesting a <repeat> or <if> inside a <repeat> body is not supported. The engine logs <repeat> nested inside <repeat> in '<file>' is not supported; skipping it, skips the inner block, and still expands the outer one. Keep the inner loop in C++, or give the inner level its own component.

Structural conditionals with <if> / <else>

Section titled “Structural conditionals with <if> / <else>”

<if cond="EXPR"> ...true-body... <else/> ...false-body... </if> builds only the matching branch — reach for it when the creation is the cost (a whole card, a chart, an alternate layout) and keep bind_flag_if ... flag="hidden" for cheap show/hide of a subtree that flips often. Semantics, the cost tradeoff, and the fact that a subject-referencing cond rebuilds repeatedly rather than once: BINDINGS.md § <if> / <else>.

<else/> is an inline divider inside the single matched <if>…</if> block, not a separate sibling tag: everything before it is the true-body, everything after it (up to </if>) is the false-body. Both spellings behave identically — self-closing <else/> and empty-element <else></else> — the split point is the <else> open tag; the marker’s own open/close events aren’t part of either body. <else> is optional: <if cond="X">…</if> with no <else> creates nothing when cond is false, and the component still loads. A second <else/> inside one <if> is a mistake — it warns and the first split wins. A stray <else/> with no enclosing <if> also warns and is ignored; the component still loads.

cond uses the same expression grammar as Expression Conditionals. A cond with no subject operands is static: it’s evaluated once at load time and the losing branch is never created — no observer. A cond that references one or more subjects is reactive: it fires immediately for the initial build, then re-evaluates and rebuilds (tears down the current branch, builds the other) on every change to any referenced subject — cond="a and b gt c" rebuilds whether a, b, or c changes.

⚠️ A reactively-rebuilt <if> must be the last child of its parent, or the only child of a dedicated container — the same ordering constraint as <repeat>. On rebuild, LVGL appends the freshly-built body to the end of the parent’s child list, so static siblings that come after the <if> in the document stay put while the rebuilt body lands after them, silently reordering the layout on every flip. A static <if> never rebuilds, so this only matters for a subject-referencing cond.

Nested <if> (an <if> inside another <if> or <repeat> body) is not supported either: it is logged and skipped the same way.

CRITICAL: When using lv_label_bind_text() with subjects in heap-allocated per-widget data, you must clean up observers before freeing.

// ✅ CORRECT - Track and remove observers
struct MyWidgetData {
lv_subject_t text_subject;
char text_buf[32];
lv_observer_t* text_observer = nullptr; // Track it!
};
// When binding:
data->text_observer = lv_label_bind_text(label, &data->text_subject, "%s");
// In DELETE handler:
static void on_delete(lv_event_t* e) {
MyWidgetData* data = get_data(e);
if (data->text_observer) {
lv_observer_remove(data->text_observer); // Remove first!
}
delete data; // Now safe
}

When a custom widget owns subjects AND has child labels bound to external subjects, the on_delete handler must detach children from all subjects before deiniting owned subjects:

static void on_delete(lv_event_t* e) {
// 1. Detach child labels from ALL subjects (external + owned)
if (data->current_label)
lv_obj_remove_from_subject(data->current_label, nullptr);
if (data->target_label)
lv_obj_remove_from_subject(data->target_label, nullptr);
// 2. NOW safe to deinit owned subjects
lv_subject_deinit(&data->owned_subject);
}

Why: lv_subject_deinit() frees observer memory. If child labels still have unsubscribe_on_delete_cb events referencing those observers, LVGL’s cascading child deletion will walk freed memory. lv_obj_remove_from_subject(label, nullptr) removes ALL observer connections from a label, including the unsubscribe_on_delete_cb events.


Our theme system sets these defaults on all lv_obj containers:

Property Default Value Notes
width content Shrinks to content size
height content Shrinks to content size
border_width 0 No border by default
bg_opa 0 Transparent background
pad_all 0 No internal padding
scrollable true NOT overridden by our theme - this is LVGL’s own default (LV_OBJ_FLAG_SCROLLABLE) and it is ON. Write scrollable="false" explicitly on any container that is not a real scroll region.

This means lv_obj acts as a pure layout container visually by default - no background, border, or padding unless explicitly added. Behaviorally it is not inert: it is still scrollable, so it can absorb drags and qualify for a page-scroll gutter. Turn that off with scrollable="false" unless the container is meant to scroll.

<!-- These are equivalent in HelixScreen -->
<lv_obj flex_flow="row">...</lv_obj>
<lv_obj flex_flow="row" height="content" style_border_width="0" style_bg_opa="0" style_pad_all="0">...</lv_obj>
<!-- ...but neither of the above is scroll-inert. A pure layout wrapper wants: -->
<lv_obj flex_flow="row" scrollable="false">...</lv_obj>

helix-screen ctl geom <name> reports the scrollable flag and the scroll extents, so it tells you directly whether a container is scrollable (see docs/devel/HELIXCTL.md#geom--why-a-widget-is-the-size-it-is).

Best for 1D layouts (single row/column or wrapping).

<lv_obj flex_flow="row"/> <!-- Horizontal left to right -->
<lv_obj flex_flow="column"/> <!-- Vertical top to bottom -->
<lv_obj flex_flow="row_reverse"/> <!-- Right to left -->
<lv_obj flex_flow="column_reverse"/><!-- Bottom to top -->
<lv_obj flex_flow="row_wrap"/> <!-- Wrap to new rows -->
<lv_obj flex_flow="column_wrap"/> <!-- Wrap to new columns -->

An item with flex_grow contributes a base size of zero when LVGL decides which track an item belongs to. So flex_grow="1" on wrapping children means everything fits one track and nothing ever wraps — the wrap silently stops happening and the items just shrink.

<!-- ❌ never wraps: grow zeroes the base width used for track fitting -->
<lv_obj flex_flow="row_wrap">
<ui_button width="48%" flex_grow="1"/> <!-- x4 -> all on one row -->
</lv_obj>
<!-- ✅ wraps 2x2; the container distributes the leftover instead -->
<lv_obj flex_flow="row_wrap" style_flex_main_place="space_between">
<ui_button width="48%"/> <!-- x4 -> two rows of two -->
</lv_obj>

To wrap and fill the row edge to edge, size the items with a percentage that forces the wrap (two at 48% fit, three don’t) and let style_flex_main_place="space_between" absorb the remainder into the gap. See ui_xml/calibration_pid_panel.xml (material presets).

Percentage height inside a content-sized parent collapses

Section titled “Percentage height inside a content-sized parent collapses”

height="100%" on a child of a height="content" parent is circular — the parent sizes to the child, the child sizes to the parent, and both resolve to zero. The children still lay out, at zero height, usually outside the parent’s box where they get clipped away and look like they were never created.

<!-- ❌ both collapse; the labels render outside the card and vanish -->
<lv_obj height="content" flex_flow="row">
<lv_obj height="100%" flex_grow="1"><text_xs text="$print_time"/></lv_obj>
</lv_obj>
<!-- ✅ -->
<lv_obj height="content" flex_flow="row">
<lv_obj height="content" flex_grow="1"><text_xs text="$print_time"/></lv_obj>
</lv_obj>

Symptom to recognise: ctl geom reports the row at a few px (its padding alone) and the children at h=0. Real example: prestonbrown/helixscreen#1208.

Text never wraps inside a flex_grow column

Section titled “Text never wraps inside a flex_grow column”

The width counterpart of the trap above, and it fails silently rather than visibly. A label left at its default width is LV_SIZE_CONTENT, so it lays out on one line however long the string is, and the parent clips the overflow. Nothing logs. It looks correct in English on a wide panel and truncates on a narrow one or in a longer language.

Adding long_mode="wrap" alone does nothing — wrap is already the LVGL default, and a content-width label has no width to wrap against. Adding width="100%" alone is worse: a percentage-sized child is dropped from its parent’s content-width calculation (w_ignore_size, lv_obj_pos.c), so a width="content" parent collapses to its widest non-percentage child.

All three attributes are one unit:

<!-- ❌ description renders 416px wide inside a 380px row, clipped -->
<lv_obj height="content" flex_flow="column" flex_grow="1">
<text_body name="label" text="$label"/>
<text_small name="description" text="$description"/>
</lv_obj>
<!-- ✅ width="0" makes the grow column's width come from the flex leftover,
which the percentage children then have something to resolve against -->
<lv_obj height="content" width="0" flex_flow="column" flex_grow="1">
<text_body name="label" width="100%" text="$label" long_mode="wrap"/>
<text_small name="description" width="100%" text="$description" long_mode="wrap"/>
</lv_obj>

width="0" is not a literal zero — for a flex_grow item it just means “take no base width into the track calculation”, which is what grow items do anyway. It exists to stop the column content-sizing itself off the unwrapped label.

A label that is a direct child of a container with a real width (a width="100%" column view, say) needs only its own width="100%" long_mode="wrap" — there is no grow column in between to neutralise. ui_xml/setting_slider_row.xml is that shape; the other setting_*_row.xml components are the three-attribute shape.

Reach for long_mode="dots" instead when the row must stay exactly one line (filenames, spool names) — then a determinate width is still required, and the string ellipsizes rather than wrapping.

Regression cover: tests/unit/test_setting_row_description_wrap.cpp asserts the wrapped label is taller than one line and never wider than the row.

Flex Alignment (Three Properties — You Need ALL THREE to Center!)

Section titled “Flex Alignment (Three Properties — You Need ALL THREE to Center!)”
Property Controls CSS Equivalent
style_flex_main_place Main axis distribution (vertical in column) justify-content
style_flex_cross_place Cross axis alignment (horizontal in column) align-items
style_flex_track_place Track alignment — required to center items with explicit widths align-content

GOTCHA: Unlike CSS, LVGL needs style_flex_track_place="center" even without flex wrap. Without it, children with explicit widths (e.g., width="80%") will be left-aligned even if style_flex_cross_place="center" is set. Always use all three for centering:

<!-- ✅ CORRECT — fully centered column layout -->
<lv_obj flex_flow="column"
style_flex_main_place="center"
style_flex_cross_place="center"
style_flex_track_place="center">
<!-- ❌ WRONG — children with explicit widths won't center horizontally -->
<lv_obj flex_flow="column"
style_flex_main_place="center"
style_flex_cross_place="center">
<!-- ❌ WRONG - flex_align is silently ignored -->
<lv_obj flex_flow="row" flex_align="center center center"/>
Value Behavior
start Beginning (left/top)
center Centered
end End (right/bottom)
space_evenly Equal space around all
space_around Equal space, double at edges
space_between No edge space, even gaps

Children with flex_grow expand to fill remaining space:

<lv_obj flex_flow="row" width="100%">
<lv_label text="Left"/> <!-- Fixed size -->
<lv_obj flex_grow="1"/> <!-- Expands -->
<lv_label text="Right"/> <!-- Fixed size -->
</lv_obj>
<!-- Equal distribution -->
<lv_obj flex_flow="row">
<lv_obj flex_grow="1">33%</lv_obj>
<lv_obj flex_grow="1">33%</lv_obj>
<lv_obj flex_grow="1">33%</lv_obj>
</lv_obj>

When using flex_grow, the parent MUST have explicit height:

<!-- ✅ Parent needs height="100%" for flex_grow to work -->
<lv_obj flex_flow="row" height="100%">
<lv_obj flex_grow="3" height="100%">Left</lv_obj>
<lv_obj flex_grow="7" height="100%">Right</lv_obj>
</lv_obj>
<lv_obj flex_flow="row"
style_pad_column="10" <!-- Horizontal gap -->
style_pad_row="5"> <!-- Vertical gap (if wrapping) -->
<!-- Text: BOTH required -->
<lv_label text="Centered" style_text_align="center" width="100%"/>
<!-- Flex centering -->
<lv_obj flex_flow="column" height="100%"
style_flex_main_place="center" style_flex_cross_place="center">
<lv_label text="Centered"/>
</lv_obj>
<!-- Row of mixed-height children in a taller container: cross_place alone
centers each child within a track only as tall as the tallest child, and
that track sits at the TOP of the container, so the children read as
top-aligned. track_place positions the track itself. -->
<lv_obj flex_flow="row" height="100%"
style_flex_cross_place="center" style_flex_track_place="center">
<lv_label text="X"/>
<lv_label text="235.00"/>
</lv_obj>
<!-- Single child: use align, NOT flex (flex conflicts with align) -->
<lv_obj width="100%" height="100%">
<lv_obj align="center">Perfectly centered</lv_obj>
</lv_obj>

Font-based icons using Material Design Icons (MDI):

<!-- Basic icon -->
<icon src="home" size="lg"/>
<!-- With color variant -->
<icon src="heater" size="lg" variant="accent"/>
<!-- Clickable icon button -->
<lv_button width="60" height="60" style_bg_opa="0">
<icon src="back" size="md" variant="primary"/>
<event_cb trigger="clicked" callback="back_clicked"/>
</lv_button>

Sizes: xs (16px), sm (24px), md (32px), lg (48px), xl (64px)

Variants: primary, secondary, accent, disabled, warning

Bindable face: the icon’s font is applied as a shared style, so bind_style_if_* can retier it - the home tiles scale a glyph with its tile through <bind_tile_rung ladder="icon" subject="$tile_icon_subject"/> (include/ui_tile_rung.h). An inline style_text_font attribute still outranks both.

Adding Icons:

  1. Find icon at Pictogrammers MDI
  2. Add codepoint to include/ui_icon_codepoints.h
  3. Add to scripts/regen_mdi_fonts.sh
  4. Run make regen-fonts

ALWAYS use semantic text components instead of <lv_label> with hardcoded fonts.

<!-- ✅ CORRECT - Semantic components -->
<text_heading text="WiFi"/>
<text_body text="Connected"/>
<text_small text="192.168.1.150"/>
<!-- ❌ WRONG - Hardcoded fonts -->
<lv_label text="WiFi" style_text_font="montserrat_20"/>
Component Purpose Responsive Sizing
<text_heading> Section titles 20px / 26px / 28px
<text_body> Primary content 14px / 18px / 20px
<text_small> Captions 12px / 16px / 18px

All support bind_text, align, style_text_color, etc.

<!-- Large spinner for modals -->
<spinner size="lg"/>
<!-- Medium for inline loading -->
<spinner size="md"/>
<!-- Small for status indicators -->
<spinner size="sm"/>

HelixScreen provides semantic widgets with built-in defaults. Don’t redundantly specify defaults!

Container with card styling from theme_core.

<!-- ✅ CORRECT - Minimal, uses defaults -->
<ui_card name="my_card" width="100%" height="200">
<text_body text="Card content"/>
</ui_card>
<!-- ❌ WRONG - Redundant, border_radius is already a default -->
<ui_card style_radius="#border_radius">

Built-in defaults: card_bg background, border_radius corners, border from theme

Semantic button with variant-based styling and auto-contrast text.

<!-- Primary action button -->
<ui_button variant="primary" text="Save"/>
<!-- Secondary button -->
<ui_button variant="secondary" text="Cancel"/>
<!-- Ghost (transparent) for toolbars -->
<ui_button variant="ghost" icon="settings"/>
<!-- Destructive action -->
<ui_button variant="danger" text="Delete"/>
<!-- Icon + text -->
<ui_button variant="primary" icon="check" text="Confirm"/>

Variants: primary, secondary, danger, success, tertiary, warning, ghost, transparent, outline (an unknown variant falls back to primary)

Built-in defaults: Responsive button_height (48/52/72px), border_radius, auto-contrast text color

Reactive text with subject binding:

ui_button supports two ways to bind text to a subject:

<!-- Literal text (static) -->
<ui_button text="Save"/>
<!-- Subject binding via text= with '@' prefix -->
<ui_button text="@my_button_text_subject"/>
<!-- Subject binding via bind_text (LVGL standard — always a subject, no '@' needed) -->
<ui_button bind_text="my_button_text_subject"/>

Both text="@subject" and bind_text="subject" produce identical reactive bindings. Use whichever reads better in context. bind_text is the LVGL-standard attribute and always expects a subject name. text with @ prefix is syntactic sugar for the same thing.

When bound to a subject, the button label updates automatically, and a deferred invalidation ensures the button background repaints correctly (avoids partial-redraw artifacts).

Breakpoint-conditional attributes:

Two ui_button attributes react to the ui_breakpoint subject, for the two ways a button outgrows a small panel. Both need the button to carry an icon and a label.

<!-- Icon over label on tiny and micro panels (ui_breakpoint <= 1), row layout above.
From ui_xml/print_status_panel.xml: a stacked button fills its growable row. -->
<ui_button name="btn_tune" flex_grow="1" icon="tune" text="Tune"
translation_tag="Tune" stacked_if_bp_lte="1"/>
<!-- Icon-only while ui_breakpoint == 1 -->
<ui_button icon="stop" text="Stop" label_hidden_if_bp_eq="1"/>
Attr Effect
stacked_if_bp_lte="N" While ui_breakpoint <= N, restacks the button icon-over-label - the create-time icon_position="top" recipe - and sets its height to 100% of its parent, so a stacked button fills the growable row it lives in. Above N the button returns to the row layout and fixed button_height it declared at create time.
label_hidden_if_bp_eq="N" Hides the button’s label while ui_breakpoint == N, collapsing it to icon-only; every other rung shows the label. The ui_breakpoint spelling of the pair below.
label_hidden_subject="S" + label_hidden_if_eq="N" Hides the label while int subject S equals N. An empty S installs no binding. From ui_xml/components/panel_widget_control_buttons.xml, where the widget folds the breakpoint rule into its own measured subject.

Give each label one writer: a widget that measures whether its label fits should fold the breakpoint rule into its own subject rather than set both attributes.

Visual separators with theme-aware colors.

<divider_vertical height="80%"/>
<divider_horizontal width="100%"/>

Built-in defaults: 1px width/height, text_muted color at 50% opacity

Markdown viewer widget that renders markdown content as native LVGL widgets. Wraps the lv_markdown library (which uses md4c for parsing) and automatically applies theme-aware styling from design tokens.

<!-- Dynamic content via subject binding -->
<ui_markdown bind_text="update_release_notes" width="100%"/>
<!-- Static content -->
<ui_markdown text="# Hello\nSome **bold** text" width="100%"/>

Attributes:

Attribute Type Description
bind_text string Binds to a string subject for dynamic markdown content
text string Sets static markdown content directly
name string Widget name for lv_obj_find_by_name() lookup
width size Width (typically 100%). Height is always LV_SIZE_CONTENT

All standard lv_obj attributes (width, height, align, hidden, etc.) are also supported.

Supported Markdown Elements:

  • Headings (H1-H6)
  • Bold (**bold**), italic (*italic*), bold-italic (***both***)
  • Inline code (`code`)
  • Fenced code blocks (```)
  • Unordered lists (- item) with nesting
  • Ordered lists (1. item) with nesting
  • Blockquotes (> quote)
  • Horizontal rules (---)

Theme-Aware Styling:

The widget automatically picks up colors, fonts, and spacing from the active theme. No manual styling is needed. The mapping is:

Element Font Token Color Token
Body text font_body text
H1 font_heading primary
H2 font_heading secondary
H3-H4 font_body text
H5-H6 font_small text_muted
Inline code font_small text on elevated_bg
Code blocks font_small text on elevated_bg
Blockquote border – primary
Horizontal rule – text_muted

Spacing uses space_sm (paragraph), space_xxs (line), and space_lg (list indent).

Bold and italic use faux rendering (letter spacing for bold, underline for italic) since separate bold/italic font files are not shipped.

Usage Pattern – Scrollable Container:

The widget uses LV_SIZE_CONTENT for height, growing to fit its content. For long content, wrap it in a scrollable container:

<ui_card width="100%" height="400" style_pad_all="#space_lg">
<lv_obj width="100%" height="100%" scrollable="true"
style_pad_all="0" style_border_width="0" style_bg_opa="0" style_radius="0">
<ui_markdown name="my_markdown" width="100%" bind_text="my_content"/>
</lv_obj>
</ui_card>

This pattern is used by the test panel. Another approach uses flex_grow to fill available space (used by the telemetry info modal):

<lv_obj width="100%" flex_grow="1"
style_pad_left="#space_lg" style_pad_right="#space_lg"
scrollable="true" scroll_snap_y="none">
<ui_markdown name="info_text" width="100%" bind_text="my_subject"/>
</lv_obj>

Setting Content from C++:

For subject-bound widgets, update the subject and the widget updates automatically. For programmatic setup (e.g., the test panel), use lv_markdown_set_text() directly:

lv_obj_t* md = lv_obj_find_by_name(lv_screen_active(), "my_markdown");
lv_markdown_set_text(md, "# Title\nSome **bold** markdown content.");

Registration:

The widget is registered via ui_markdown_init() in xml_registration.cpp. This must be called after lv_xml_init() and after the theme is initialized. No XML file registration is needed – it is a custom C++ widget, not an XML component.

Limitations and Gotchas:

  • No image/link support – markdown images and hyperlinks are not rendered
  • LVGL spangroups do not support per-span background styles, so inline code background color (code_bg_color) has no visible effect
  • Theme changes at runtime do not automatically re-style existing markdown widgets (the style is applied at creation time)
  • The text attribute in XML does not support literal newlines; use \n for line breaks in static content
  • When using bind_text, the observer does not use ObserverGuard – the observer is cleaned up automatically when the widget is deleted via LVGL’s built-in observer-object tracking

moves_machine="true" on any widget that commands the toolhead or starts a print makes the XML engine bind its disabled state to machine_motion_blocked (lib/helix-xml/src/xml/parsers/lv_xml_obj_parser.c). That subject is job_holds_machine (a print owns the machine) or the spools-on-the-bed latch (BED_DRYING.md). The attribute composes with the widget’s own bind_state_* children, and tests/unit/test_job_holds_machine.cpp keeps a census of every control that should carry it. Disabling is a courtesy; the send-layer gates are the guarantee.

One centred icon over one label, filling its cell (ui_xml/components/home_action_tile.xml). The home action tiles and every cell of the Controls panel’s Calibration & Tools card, plus Motors Off on its Position card, are built from it, so a row of them aligns by construction. Extend its props rather than hand-building a look-alike cell.

Prop Default Purpose
icon, icon_variant, icon_size power, secondary, #icon_size The glyph
label, label_tag empty The label and its translation tag
callback empty Clicked callback on the inner button
disabled_cond 0 eq 1 Whole expression; the default can never be true
label_hidden_cond show_widget_labels eq 0 Whole expression; a cell outside the home grid can follow the breakpoint instead
moves_machine false Forwarded to the inner button (see moves_machine)
alt_icon, alt_icon_variant, icon_swap_subject power, secondary, empty Shows alt_icon in place of icon while the subject reads 0; empty installs no binding
button_name, icon_name home_action_tile_button, home_action_tile_icon Names C++ looks up; keep the one the owning class already finds
tile_icon_subject, tile_label_subject empty Per-instance size rung from a home tile; empty keeps #icon_size

One pre-print option as a checkable tile: an icon and a two-line label in an outline, with a corner check tab that appears only while checked (ui_xml/components/option_tile.xml). The whole tile is the tap target. Its view sets state_trickle, so the tile’s checked state reaches the children and each child styles itself with a -checked state style (the border turns #primary, the icon tints, the tab’s style_opa goes 255). You do not instantiate it from XML: PrePrintOptionsRenderer creates one tile per option and binds the option’s subject to the tile’s checked state (src/ui/ui_pre_print_options_renderer.cpp). The tile height and the tab’s negative lift come from the option_tile_height_* / option_tile_tab_lift_* token ladders in ui_xml/globals.xml.

Prop Default Purpose
label, label_tag Option, empty The label and its translation tag
icon tune The glyph
callback empty value_changed callback; the renderer wires the toggle

A card of settings rows under a setting_group_header. When every row in it is hidden, the group hides its header and collapses (the LV_STATE_USER_1 style zeroes its margin, border and background), and it comes back when a row shows again. LVGL sends no event when a child’s hidden flag changes, but any row appearing or disappearing changes the group’s height, so the check runs on LV_EVENT_SIZE_CHANGED (src/ui/setting_group.cpp#setting_group_sync_header). A row counts when it is visible with a non-zero height, so a wrapper that groups gated rows must have style_pad_all="0": padding gives an empty wrapper height, and its header would stay.

Widget Don’t Specify (Built-in)
ui_card style_radius, style_bg_color, style_border_*
ui_button style_radius, style_bg_color, style_height, text color
text_* style_text_font, style_text_color
icon Font selection
divider_* style_bg_color, width/height (1px)
ui_markdown All styling (theme-aware fonts, colors, spacing)

HelixScreen has a responsive design token system with 7 breakpoints, semantic spacing, responsive fonts, and more. See the UI Contributor Guide for the complete reference — it covers breakpoints, spacing tokens, font tokens, component tokens, color system, and how to add new tokens.

Quick summary for reference:

  • Breakpoints are selected from the narrow axis, min(width, height), not the height: MICRO (≤272), TINY (273-390), SMALL (391-460), MEDIUM (461-550), LARGE (551-700), XLARGE (701-1000), XXLARGE (>1000)
  • Spacing: #space_xxs through #space_2xl — always use tokens, never hardcoded pixels
  • Fonts: Use <text_heading>, <text_body>, <text_small>, <text_xs> components
  • Colors: Use #token_name in XML (e.g., style_bg_color="#card_bg")

CRITICAL: Inside <styles>, do NOT use style_ prefix!

<styles>
<!-- ✅ CORRECT - No prefix in style definitions -->
<style name="style_button" bg_color="0x111" radius="8" pad_all="12"/>
<!-- ❌ WRONG - style_ prefix doesn't work here -->
<style name="bad_style" style_bg_color="0x111"/>
</styles>

A <styles> block is file-local; share a style through ui_xml/styles.xml. A bare style name resolves in the file that declares it, then in globals. A component nested inside another file cannot see that file’s <styles>. When two files need the same look, define it once in ui_xml/styles.xml and borrow it from anywhere by dotted name, where the prefix is the library’s basename:

ui_xml/styles.xml
<style name="press_wash" bg_color="#primary" bg_opa="30%" radius="#border_radius"/>
<!-- any other file: applied directly, or through a binding -->
<style name="styles.press_wash" selector="pressed"/>
<bind_style name="styles.invisible" subject="preparing_visible" ref_value="1"/>

The library registers after theme init, so #token values resolve there, which a style in globals.xml cannot do. In-tree borrowers: components/filament_catalog_row.xml, components/print_status_preview_card.xml. Keep the library small; the borrowed-pointer lifetime rule is in UI_CONTRIBUTOR_GUIDE.md § “Shared styles”.

flex_flow in a <style> is inert without layout="flex". Setting the flow alone is a no-op: lv_obj_set_flex_flow() sets both LAYOUT and FLEX_FLOW, but a <style> only applies the properties you name. A container with a flow and no layout runs no layout at all — every child stacks at the content origin, on top of each other.

<!-- ❌ no layout runs; children pile up at the top-left -->
<style name="card_row" flex_flow="row"/>
<!-- ✅ -->
<style name="card_row" layout="flex" flex_flow="row"/>

This matters whenever a layout is switched per breakpoint via bind_style_if_*, since the flow can only live in the style — an inline flex_flow attribute would beat the bound style (see Rule 6). Examples: ui_xml/components/lock_screen.xml, ui_xml/ams_environment_overlay.xml.

<!-- By name -->
<lv_button>
<style name="style_button"/>
</lv_button>
<!-- With state selector -->
<lv_button>
<style name="style_base"/>
<style name="style_pressed" selector="pressed"/>
</lv_button>
<!-- Inline (USE style_ prefix) -->
<lv_button style_bg_color="0x111" style_radius="8"/>

Many widgets have styleable parts:

<!-- Style slider knob separately -->
<lv_slider style_bg_color="#333333"
style_bg_color:indicator="#primary_color"
style_bg_color:knob="#ffffff"/>
<!-- Hide spinner background track -->
<lv_spinner style_arc_opa:main="0"/>
Part Widgets
main All (background)
indicator slider, bar, arc, spinner
knob slider, arc
items dropdown, roller
scrollbar Scrollable containers

A style can animate a property change between states instead of snapping to it, in either of two spellings.

Longhand — four separate attributes:

<style name="t" transition_props="opa|transform_scale_x"
transition_duration="180" transition_easing="ease_out"
transition_delay="20"/>

Shorthand — one CSS-style attribute, transition="<props> <duration> [easing] [delay]":

<style name="t" transition="opa|transform_scale_x 200ms ease_out 30ms"/>
<!-- easing and delay are both optional -->
<style name="t" transition="opa 90"/>

transition_props (longhand) accepts |, , or whitespace to separate names, interchangeably — the same delimiter set parts= accepts. The shorthand’s own property-list token can use | or , but not a bare space: the shorthand already splits its whole value on whitespace to find the props/duration/easing/delay fields, so a space inside the property list would read as the end of it. Duration and delay are milliseconds (a trailing ms is accepted and ignored; a bare number is milliseconds too), and easing is one of:

Easing
linear (default) ease_in ease_out
ease_in_out overshoot bounce
step

The two spellings can combine on one <style> element; a longhand attribute always wins its own field over the shorthand, whichever attribute the XML happens to list first:

<!-- duration is 55, easing is still ease_out - the shorthand's duration is
shadowed by the explicit longhand attribute, not overwritten by it -->
<style name="t" transition="opa 200ms ease_out" transition_duration="55"/>

A bad value warns by name and is refused — it never guesses. An unrecognised transition_easing, longhand or shorthand, warns and falls back to linear, exactly like an omitted easing. A duration or delay that is not a plain non-negative integer (optionally ms-suffixed) warns and is refused the same way; a value ending in a bare s (transition_duration="0.2s", the most common way to carry a CSS habit over) gets its own message, since every duration in this dialect is milliseconds, never seconds. A refused longhand field falls back to whatever the shorthand on the same element supplied, or to the default (0, linear) if neither did — only a transition_props list with no valid property left in it refuses the whole transition, since there is nothing left to build without one.

A few things about where a transition takes effect are not guessable from the syntax:

  • Put transition on the base style for symmetric motion. LVGL scans every style on the object whose state bits are a subset of the state being entered (update_obj_state’s style compare, lib/lvgl/src/core/lv_obj.c#"lv_obj_style_state_compare(obj, prev_state, new_state)"), and LV_STATE_DEFAULT is 0 — a subset of every state — so a transition on the base style is scanned on every state change, in both directions. A transition declared only on a <style selector="pressed"> still applies when entering pressed; leaving pressed snaps, because that style’s bits are not a subset of the (default) state being entered. Add a state-specific style carrying its own transition only when you deliberately want asymmetric timing — that is what lv_theme_default does for a button: an instant press and a 70ms-delayed release, by putting one transition on the base style and a second, faster one on LV_STATE_PRESSED (the button branch of theme_apply, lib/lvgl/src/themes/default/lv_theme_default.c#"lv_obj_check_type(obj, &lv_button_class)"). selector itself is read only when a <style> child is applied to a widget (lib/helix-xml/src/xml/parsers/lv_xml_obj_parser.c#lv_obj_xml_style_apply) — a style’s own <styles> definition has no selector handling, so the two roles never collapse into one tag:

    <styles>
    <!-- bare definitions - no selector here -->
    <style name="btn_base" transition="bg_opa 200ms"/>
    <style name="btn_pressed" bg_opa="128"/>
    </styles>
    <lv_button>
    <style name="btn_base"/>
    <style name="btn_pressed" selector="pressed"/> <!-- selector lives here -->
    </lv_button>
  • The state-specific style must still set the animated property itself. Two equal endpoints are a silent no-op — there is nothing to interpolate — and this bites text_opa in particular, since it is inheritable: a value set on a parent reads as already applied to a child that never set it locally, so the child’s state style must set text_opa explicitly even though it “already has” that opacity by inheritance.

  • LV_STATE_CHECKED carries a themed background on buttons. A transition on a checked button’s bg_color/bg_opa fights the theme’s own checked-state style, which already carries its own background/transform transition — text_opa and text_color are not in that set, so a text-opacity transition does not contend with it. Prefer putting checked-state transitions on labels, or override the button’s background explicitly in the checked style.

  • Declaring the same property in two transitions on one widget is order-dependent. If a base style and a state style both name the same property in their transition_props, which descriptor governs it depends on style application order, not on anything either transition declares. Put each property in exactly one transition per widget.

Every duration is scaled by the animations preference, and can become 0. HelixScreen drives lv_xml_set_transition_scale() from the animations-enabled setting (src/system/display_settings_manager.cpp#init_subjects); with animations off, every duration authored in XML runs as 0 — instant — no matter what the XML says. HelixTestFixture forces that preference off suite-wide (tests/helix_test_fixture.cpp#reset_all), so a test asserting on a transition’s timing must turn it on explicitly, as tests/unit/test_xml_transition_pref.cpp does. This is the first thing to check when a transition “does nothing”: it may be doing exactly what it was told, at 0ms.

The shorthand cannot take a #const for just the duration. The style attribute loop resolves a #name reference only when # is the first character of the whole attribute value (lib/helix-xml/src/xml/lv_xml_style.c#lv_xml_register_style). transition="opa #anim_fast" has # at an offset into the value, not at 0, so it is never resolved and is parsed literally as a duration token, which then fails. Use the longhand transition_duration="#anim_fast" when a duration needs to come from a const.

transition_duration is not anim_duration. anim_duration is LVGL’s own style property, controlling a widget’s internal animation (a switch knob’s slide, a label’s scroll) and has no effect on a state change. transition/transition_duration is this dialect’s name for LVGL’s separate state-transition mechanism (lv_style_set_transition), and only that.

A few of LVGL’s own constraints shape what the parser accepts — worth knowing before extending this feature rather than working around it:

  • The transition interpolator is a blacklist, not a whitelist. trans_anim_cb (lib/lvgl/src/core/lv_obj_style.c#"switch(tr->prop)") switches on only the properties that cannot interpolate; everything else falls through to a generic numeric lerp. A pointer property absent from that blacklist (bg_image_src, bg_grad, bitmap_mask_src, the grid *_dsc_array pair, arc_image_src) has its low 32 bits arithmetically blended and the result handed to the draw pass as a pointer — a crash. Nothing in LVGL itself validates this when a transition is built, so the XML parser is the only place a bad property is ever caught, which is why an unrecognised or non-interpolatable property in transition_props refuses the whole transition rather than warning and continuing.
  • The style owns its transition descriptor and its property array. lv_xml_style_t (lib/helix-xml/src/xml/lv_xml_style.h) frees the previous descriptor and array before installing a new one, and only in the style walk inside component_scope_free, at lib/helix-xml/src/xml/lv_xml_component.c#"lv_xml_style_transition_clear(style)" — which is what keeps a globals.xml hot reload (re-registering the same style name re-runs every setter over the existing record) from orphaning one descriptor per save, without ever freeing a descriptor a running transition is still reading from (LVGL copies duration, delay, path and property list out of the descriptor before animating it).
  • A widget that has never been drawn snaps instead of animating. update_obj_state returns early from the transition scan for an object that has not rendered yet, so state set during construction or by an initial data bind never animates — there is no startup-flash to guard against.
  • STYLE_TRANSITION_MAX caps transitioning properties at 32 per state change, across every style on one object, theme styles included.
// ✅ For theme tokens - handles light/dark mode:
lv_color_t bg = theme_manager_get_color("card_bg");
lv_color_t ok = theme_manager_get_color("success_color");
// ✅ For literal hex strings:
lv_color_t custom = theme_manager_parse_hex_color("#FF4444");
// ❌ WRONG - parse_hex_color doesn't look up tokens:
// lv_color_t bg = theme_manager_parse_hex_color("#card_bg"); // Garbage!

Events MUST be declared in XML and registered in C++. NEVER use lv_obj_add_event_cb().

Step 1: Declare in XML

<lv_button name="my_button">
<event_cb trigger="clicked" callback="on_my_button_clicked"/>
<text_body text="Click Me"/>
</lv_button>
<!-- Multiple events -->
<lv_slider name="my_slider">
<event_cb trigger="value_changed" callback="on_slider_changed"/>
<event_cb trigger="released" callback="on_slider_released"/>
</lv_slider>

Step 2: Register in init_subjects() (BEFORE XML creation)

void MyPanel::init_subjects() {
// Register callbacks BEFORE XML is created
lv_xml_register_event_cb(nullptr, "on_my_button_clicked", on_click_cb);
lv_xml_register_event_cb(nullptr, "on_slider_changed", on_slider_cb);
}

Step 3: Implement callback

static void on_click_cb(lv_event_t* e) {
spdlog::info("Button clicked!");
}
// Or use lambda
lv_xml_register_event_cb(nullptr, "on_slider_changed", [](lv_event_t* e) {
lv_obj_t* slider = lv_event_get_current_target(e);
int value = lv_slider_get_value(slider);
spdlog::info("Slider: {}", value);
});
Trigger When Fired
clicked Button click (press + release)
value_changed Slider, dropdown, switch
pressed Object pressed down
released Object released
long_pressed Long press detected
focused Object gains focus
ready Text area complete

ui_xml/example_panel.xml:

<component>
<view extends="lv_obj" width="100%" height="100%" style_bg_color="#overlay_bg">
<!-- Bound to subject -->
<text_body bind_text="example_status"/>
<!-- Conditional visibility -->
<lv_obj name="loading_view">
<bind_flag_if_eq subject="panel_state" flag="hidden" ref_value="1"/>
<spinner size="lg"/>
</lv_obj>
<lv_obj name="content_view">
<bind_flag_if_not_eq subject="panel_state" flag="hidden" ref_value="1"/>
<lv_button>
<event_cb trigger="clicked" callback="on_action_clicked"/>
<text_body text="Action"/>
</lv_button>
</lv_obj>
</view>
</component>

include/example_panel.h:

#pragma once
#include "lvgl/lvgl.h"
class ExamplePanel {
public:
static void init_subjects();
static lv_obj_t* create(lv_obj_t* parent);
static void update_status(const char* msg);
static void show_loading();
static void show_content();
};

src/example_panel.cpp:

#include "example_panel.h"
#include <spdlog/spdlog.h>
static lv_subject_t status_subject;
static lv_subject_t state_subject;
static char status_buffer[128];
void ExamplePanel::init_subjects() {
// Initialize subjects
lv_subject_init_string(&status_subject, status_buffer, NULL,
sizeof(status_buffer), "Ready");
lv_subject_init_int(&state_subject, 0);
// Register subjects
lv_xml_register_subject(NULL, "example_status", &status_subject);
lv_xml_register_subject(NULL, "panel_state", &state_subject);
// Register event callbacks
lv_xml_register_event_cb(nullptr, "on_action_clicked", [](lv_event_t* e) {
spdlog::info("Action clicked!");
});
}
lv_obj_t* ExamplePanel::create(lv_obj_t* parent) {
return lv_xml_create(parent, "example_panel", nullptr);
}
void ExamplePanel::update_status(const char* msg) {
lv_subject_copy_string(&status_subject, msg);
}
void ExamplePanel::show_loading() {
lv_subject_set_int(&state_subject, 0);
}
void ExamplePanel::show_content() {
lv_subject_set_int(&state_subject, 1);
}

In main.cpp:

// 1. Register component
lv_xml_register_component_from_file("A:ui_xml/example_panel.xml");
// 2. Initialize subjects (BEFORE creating XML)
ExamplePanel::init_subjects();
// 3. Create panel
lv_obj_t* panel = ExamplePanel::create(screen);
// 4. Update (triggers reactive updates)
ExamplePanel::update_status("Loading...");
ExamplePanel::show_loading();

<lv_label name="temperature_display" bind_text="temp"/>
// ✅ CORRECT - Name-based (resilient)
lv_obj_t* w = helix::ui::find_required(parent, "temperature_display", "TempPanel");
// ❌ WRONG - Index-based (fragile)
lv_obj_t* w = lv_obj_get_child(parent, 3);

App code looks widgets up through the two helpers in include/ui/ui_widget_helpers.h rather than calling lv_obj_find_by_name() directly:

  • find_required(root, name, owner) is for a widget the component’s XML must contain. A missing name is a breached contract: it logs once per (owner, name) and returns nullptr in a release build, and aborts under --test and in unit tests (set_strict_ui_checks()), so a rename in XML fails the run where the C++ disagrees instead of shipping a dead control. scripts/check_required_names.py (in the commit hook) checks each literal name against every layout variant of the component the calling file creates.
  • find_optional(root, name) is for a widget that may legitimately be absent: inside an <if>, omitted by one layout variant, or in plugin-supplied XML. It never reports.

Both return nullptr silently for a null root, so a lookup nested under a failed one does not report twice.

<!-- ❌ WRONG - Component not findable -->
<controls_panel/>
<!-- ✅ CORRECT - Explicit name -->
<controls_panel name="controls_panel"/>

Widgets must have names when:

  1. C++ lookup - Referenced via lv_obj_find_by_name()
  2. Interactive types - lv_button, lv_slider, lv_dropdown, lv_spinner, lv_textarea
  3. Subject binding - Has bind_text=, bind_value= attributes

Widgets can safely omit names:

  • Layout containers - Pure flexbox structure: <lv_obj flex_flow="row" style_pad_gap="...">
  • Spacers/dividers - One-pixel separators: <lv_obj width="100%" height="1">
  • Static labels - No binding, not looked up: <lv_label text="Section Title"/>
  • Decorative buttons - clickable="false" placeholders
<!-- These DON'T need names (decorative) -->
<lv_obj flex_flow="row" style_pad_gap="#space_md">
<lv_obj width="1" height="100%" style_bg_color="#text_muted"/>
<lv_label text="Settings"/>
</lv_obj>
<!-- These DO need names (interactive/bound) -->
<lv_button name="save_btn">
<lv_label name="status_display" bind_text="status_subject"/>

Note: The audit script (scripts/audit_codebase.sh, P5 section) uses smart detection to only warn on truly interactive unnamed widgets. Decorative containers are ignored.

Pattern Why Banned Alternative
lv_obj_add_event_cb() Tight coupling XML <event_cb>
lv_label_set_text() Bypasses binding bind_text subject
lv_obj_add_flag(HIDDEN) Visibility is UI <bind_flag_if_eq>
lv_obj_set_style_*() Styling in XML Design tokens
  1. LV_EVENT_DELETE cleanup
  2. Widget pool recycling (virtual scroll)
  3. Chart data points
  4. Animations
  5. One-time setup() widget lookup

<!-- ✅ CORRECT -->
<lv_obj width="content" height="content"/>
<!-- ❌ WRONG - Parses as 0! -->
<lv_obj width="LV_SIZE_CONTENT"/>
<!-- ❌ WRONG - zoom doesn't exist -->
<lv_image src="icon" zoom="128"/>
<!-- ✅ CORRECT - use scale (256 = 100%) -->
<lv_image src="icon" scale_x="128" scale_y="128"/>
<!-- ❌ WRONG -->
<lv_image style_img_recolor="#ff0000"/>
<!-- ✅ CORRECT -->
<lv_image style_image_recolor="#ff0000"/>
<!-- ✅ CORRECT - XML entity -->
<lv_dropdown options="A&#10;B&#10;C"/>
<!-- ❌ WRONG - Literal \n doesn't work -->
<lv_dropdown options="A\nB\nC"/>

5. Complex Layouts Need lv_obj_update_layout()

Section titled “5. Complex Layouts Need lv_obj_update_layout()”

Grid layouts or dynamic content with SIZE_CONTENT may need an explicit layout update:

lv_obj_t* panel = lv_xml_create(parent, "complex_panel", NULL);
lv_obj_update_layout(panel); // Required for grid layouts

Note: SIZE_CONTENT disables flex wrapping - use explicit width if you need row_wrap.

Bar shows FULL instead of empty when created with cur_value=0 and XML sets value=0. lv_bar_set_value() returns early without invalidation because old == new. Workaround: set to 1 then 0.

lv_bar_set_value(bar, 1, LV_ANIM_OFF);
lv_bar_set_value(bar, 0, LV_ANIM_OFF);

7. Component Names Are File Basenames, Not Paths

Section titled “7. Component Names Are File Basenames, Not Paths”

A component is named by its file’s basename, so ui_xml/micro/controls_panel.xml and ui_xml/controls_panel.xml both register as controls_panel. Registering the second replaces the first for every later lv_xml_create() in that process, silently.

Six names exist in both the base tree and a variant directory: controls_panel, header_bar, print_status_panel, print_tune_panel, theme_editor_overlay, theme_preview_overlay.

Tests feel this most, because registering everything under ui_xml/ so nested components resolve is the obvious move and the wrong one:

// ❌ WRONG - the micro variant replaces the base panel for the rest of the run
for (const auto& f : all_xml_files) lv_xml_register_component_from_file(f.c_str());
// ✅ CORRECT - base tree and components/ once, then the file under test
// immediately before building it
register_base_and_components();
lv_xml_register_component_from_file("A:ui_xml/micro/controls_panel.xml");
lv_obj_t* panel = lv_xml_create(parent, "controls_panel", NULL);

A file whose <view> carries a name the base tree also uses cannot be built alongside the panel it shadows. Assert on its source text instead.

8. lv_obj_find_by_name() Searches Descendants Only

Section titled “8. lv_obj_find_by_name() Searches Descendants Only”

It never tests the object handed to it, so a component carrying its name or its bindings on its own <view> element reads as missing:

// ❌ WRONG - NULL when `root` IS the named widget
lv_obj_t* w = lv_obj_find_by_name(root, "ams_current_tool");
// ✅ CORRECT - test the root, then its descendants
const char* root_name = lv_obj_get_name(root);
lv_obj_t* w = (root_name && strcmp(root_name, "ams_current_tool") == 0)
? root
: lv_obj_find_by_name(root, "ams_current_tool");

ams_current_tool is built this way.

When layouts don’t work:

  • Label has style_text_align="center" AND width="100%"?
  • Parent has flex_flow set?
  • Using style_flex_main_place (NOT flex_align)?
  • Children have flex_grow="1"?
  • Container has height="100%"?
  • No mixing absolute positioning with flex?

Add temporary background colors:

<lv_obj style_bg_color="#ff0000" style_bg_opa="100%">
<!-- Check actual size -->
</lv_obj>

// Component registration
lv_xml_register_component_from_file("A:path/file.xml");
// Subject registration
lv_xml_register_subject(NULL, "name", &subject);
// Event callback registration
lv_xml_register_event_cb(nullptr, "callback_name", function);
// Font registration
lv_xml_register_font(NULL, "font_name", &font);
// Constant registration
lv_xml_register_const(scope, "name", "value");
// Create component
lv_obj_t* obj = lv_xml_create(parent, "component_name", nullptr);
// Find widget by name
lv_obj_t* w = lv_obj_find_by_name(parent, "widget_name");

  • Subject-Observer: https://docs.lvgl.io/master/details/auxiliary-modules/observer/
  • Fork origin and licensing: HELIX_XML_FORK.md
  • Upstream XML docs: LVGL removed XML from core in v9.5 and now sells it as LVGL Pro. The old docs.lvgl.io/master/details/xml/ link redirects to https://lvgl.io/docs/pro/syntax, which documents a different, closed engine — it is not authoritative for helix-xml syntax. Read it for background only, and never read LVGL Pro source (see HELIX_XML_FORK.md § Clean-room rule).
  • Quick Reference: LVGL9_XML_ATTRIBUTES_REFERENCE.md
  • Example Panels: ui_xml/bed_mesh_panel.xml (gold standard)