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
Table of Contents
Section titled “Table of Contents”- Overview & Architecture
- Project Structure
- Core Concepts
- Layouts & Positioning
- Common UI Patterns
- Responsive Design
- Styles & Theming
- Event Handling
- Implementation Guide
- Best Practices
- Troubleshooting
Overview & Architecture
Section titled “Overview & Architecture”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.
Architecture Diagram
Section titled “Architecture Diagram”┌─────────────────┐│ 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)│└─────────────────┘Reactive Data Binding is MANDATORY
Section titled “Reactive Data Binding is MANDATORY”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 automaticallyProject Structure
Section titled “Project Structure”HelixScreen Directory Layout
Section titled “HelixScreen Directory Layout”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 cheatsheetRegistration Flow (main.cpp + xml_registration.cpp)
Section titled “Registration Flow (main.cpp + xml_registration.cpp)”// 1. Register fontslv_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.
Core Concepts
Section titled “Core Concepts”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 — inlib/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, ourui_breakpointtiers, and the conventions that only make sense against this codebase.
1. XML Components
Section titled “1. XML Components”Components are reusable UI pieces defined with the <component> tag.
Basic Structure
Section titled “Basic Structure”<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>Property Types
Section titled “Property Types”| 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 |
Slot Injection into a Component Instance
Section titled “Slot Injection into a Component Instance”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.
2. Subjects (Reactive Data)
Section titled “2. Subjects (Reactive Data)”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.
Subject Types (C++)
Section titled “Subject Types (C++)”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)Subject Lifecycle
Section titled “Subject Lifecycle”// 1. Create subject in C++static lv_subject_t status_subject;static char status_buffer[128];
// 2. Initialize with default valuelv_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.
Static Buffers Required
Section titled “Static Buffers Required”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.
3. Data Binding
Section titled “3. Data Binding”Sigil Conventions
Section titled “Sigil Conventions”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.
Simple Attribute Bindings
Section titled “Simple Attribute Bindings”<!-- 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 abind_style_if_eq/bind_style_ifon the widget (see the “Reactive styles” section below), or drive the color through a themed token.
Note: Standard LVGL widgets (
lv_label,lv_slider) resolvebind_textdirectly as a subject name. The@prefix convention is specific toui_button, which needs to disambiguate between literal button labels and subject references.
Inline Text Content
Section titled “Inline Text Content”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=, ortranslation_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
) collapse to a single space. For multi-line label text, usetext="Line1 Line2". $prop/#constresolve 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. Usetext="$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
textignore inline content silently, like any unknown attribute. - Inline text on the root
<view>element of a component is not supported – it’s silently dropped. Usetext=/bind_text=on the view’s opening tag, or put the inline text on a child element instead.
Conditional Flag Bindings (Show/Hide)
Section titled “Conditional Flag Bindings (Show/Hide)”<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
Conditional State Bindings
Section titled “Conditional State Bindings”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.
Conditional Style Bindings
Section titled “Conditional Style Bindings”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.
Expression Conditionals
Section titled “Expression Conditionals”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.
HelixScreen examples
Section titled “HelixScreen examples”<!-- 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>Parse-Time Conditional Hidden Attributes
Section titled “Parse-Time Conditional Hidden Attributes”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.
Binding Limitations
Section titled “Binding Limitations”❌ 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.
Repeating fragments with <repeat>
Section titled “Repeating fragments with <repeat>”<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#constcountnever rebuilds, so this only matters for subject-boundcount. Fix: give the<repeat>its own container (an<lv_obj>wrapper with no other children), or make it the last element inside its parent.
Self-wiring indexed subjects with ${name}
Section titled “Self-wiring indexed subjects with ${name}”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 abind_*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-referencingcond.
Nested <if> (an <if> inside another <if> or <repeat> body) is not supported either: it is logged and skipped the same way.
4. Observer Cleanup in DELETE Handlers
Section titled “4. Observer Cleanup in DELETE Handlers”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 observersstruct 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}Custom Widget on_delete Cleanup Ordering
Section titled “Custom Widget on_delete Cleanup Ordering”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.
Layouts & Positioning
Section titled “Layouts & Positioning”lv_obj Defaults (HelixScreen Theme)
Section titled “lv_obj Defaults (HelixScreen Theme)”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).
Flex Layout (Flexbox)
Section titled “Flex Layout (Flexbox)”Best for 1D layouts (single row/column or wrapping).
Flex Flow Options
Section titled “Flex Flow Options”<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 -->flex_grow does NOT compose with *_wrap
Section titled “flex_grow does NOT compose with *_wrap”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"/>Alignment Values
Section titled “Alignment Values”| 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 |
Flex Grow
Section titled “Flex Grow”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>CRITICAL: Parent Height Required
Section titled “CRITICAL: Parent Height Required”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>Flex Gaps
Section titled “Flex Gaps”<lv_obj flex_flow="row" style_pad_column="10" <!-- Horizontal gap --> style_pad_row="5"> <!-- Vertical gap (if wrapping) -->Centering Techniques
Section titled “Centering Techniques”<!-- 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>Common UI Patterns
Section titled “Common UI Patterns”Icon Component
Section titled “Icon Component”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:
- Find icon at Pictogrammers MDI
- Add codepoint to
include/ui_icon_codepoints.h - Add to
scripts/regen_mdi_fonts.sh - Run
make regen-fonts
Semantic Typography
Section titled “Semantic Typography”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.
Spinner (Loading Indicator)
Section titled “Spinner (Loading Indicator)”<!-- Large spinner for modals --><spinner size="lg"/>
<!-- Medium for inline loading --><spinner size="md"/>
<!-- Small for status indicators --><spinner size="sm"/>Custom Semantic Widgets
Section titled “Custom Semantic Widgets”HelixScreen provides semantic widgets with built-in defaults. Don’t redundantly specify defaults!
ui_card
Section titled “ui_card”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
ui_button
Section titled “ui_button”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.
divider_vertical / divider_horizontal
Section titled “divider_vertical / divider_horizontal”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
ui_markdown
Section titled “ui_markdown”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
textattribute in XML does not support literal newlines; use\nfor line breaks in static content - When using
bind_text, the observer does not useObserverGuard– the observer is cleaned up automatically when the widget is deleted via LVGL’s built-in observer-object tracking
moves_machine
Section titled “moves_machine”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.
home_action_tile
Section titled “home_action_tile”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 |
option_tile
Section titled “option_tile”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 |
setting_group
Section titled “setting_group”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 Defaults Quick Reference
Section titled “Widget Defaults Quick Reference”| 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) |
Responsive Design
Section titled “Responsive Design”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_xxsthrough#space_2xl— always use tokens, never hardcoded pixels - Fonts: Use
<text_heading>,<text_body>,<text_small>,<text_xs>components - Colors: Use
#token_namein XML (e.g.,style_bg_color="#card_bg")
Styles & Theming
Section titled “Styles & Theming”Defining Styles
Section titled “Defining Styles”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:
<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.
Applying Styles
Section titled “Applying Styles”<!-- 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"/>Part Selectors
Section titled “Part Selectors”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 |
Transitions
Section titled “Transitions”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
transitionon 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)"), andLV_STATE_DEFAULTis0— 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 whatlv_theme_defaultdoes for a button: an instant press and a 70ms-delayed release, by putting one transition on the base style and a second, faster one onLV_STATE_PRESSED(the button branch oftheme_apply,lib/lvgl/src/themes/default/lv_theme_default.c#"lv_obj_check_type(obj, &lv_button_class)").selectoritself 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 noselectorhandling, 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_opain 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 settext_opaexplicitly even though it “already has” that opacity by inheritance. -
LV_STATE_CHECKEDcarries a themed background on buttons. A transition on a checked button’sbg_color/bg_opafights the theme’s own checked-state style, which already carries its own background/transform transition —text_opaandtext_colorare 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.
Engine internals
Section titled “Engine internals”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_arraypair,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 intransition_propsrefuses 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 insidecomponent_scope_free, atlib/helix-xml/src/xml/lv_xml_component.c#"lv_xml_style_transition_clear(style)"— which is what keeps aglobals.xmlhot 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_statereturns 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_MAXcaps transitioning properties at 32 per state change, across every style on one object, theme styles included.
Theme Colors (C++ API)
Section titled “Theme Colors (C++ API)”// ✅ 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!Event Handling
Section titled “Event Handling”The Mandatory Pattern
Section titled “The Mandatory Pattern”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 lambdalv_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);});Common Triggers
Section titled “Common Triggers”| 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 |
Implementation Guide
Section titled “Implementation Guide”Step-by-Step Pattern
Section titled “Step-by-Step Pattern”1. Create XML Layout
Section titled “1. Create XML Layout”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>2. Create C++ Wrapper
Section titled “2. Create C++ Wrapper”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);}3. Register and Use
Section titled “3. Register and Use”In main.cpp:
// 1. Register componentlv_xml_register_component_from_file("A:ui_xml/example_panel.xml");
// 2. Initialize subjects (BEFORE creating XML)ExamplePanel::init_subjects();
// 3. Create panellv_obj_t* panel = ExamplePanel::create(screen);
// 4. Update (triggers reactive updates)ExamplePanel::update_status("Loading...");ExamplePanel::show_loading();Best Practices
Section titled “Best Practices”Widget Lookup - Use Names
Section titled “Widget Lookup - Use Names”<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--testand 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.
Component Names Required
Section titled “Component Names Required”<!-- ❌ WRONG - Component not findable --><controls_panel/>
<!-- ✅ CORRECT - Explicit name --><controls_panel name="controls_panel"/>Widget Naming Strategy
Section titled “Widget Naming Strategy”Widgets must have names when:
- C++ lookup - Referenced via
lv_obj_find_by_name() - Interactive types -
lv_button,lv_slider,lv_dropdown,lv_spinner,lv_textarea - 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.
Banned Patterns
Section titled “Banned Patterns”| 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 |
Acceptable Exceptions
Section titled “Acceptable Exceptions”LV_EVENT_DELETEcleanup- Widget pool recycling (virtual scroll)
- Chart data points
- Animations
- One-time
setup()widget lookup
Troubleshooting
Section titled “Troubleshooting”Critical Gotchas
Section titled “Critical Gotchas”1. SIZE_CONTENT Syntax
Section titled “1. SIZE_CONTENT Syntax”<!-- ✅ CORRECT --><lv_obj width="content" height="content"/>
<!-- ❌ WRONG - Parses as 0! --><lv_obj width="LV_SIZE_CONTENT"/>2. No zoom Attribute
Section titled “2. No zoom Attribute”<!-- ❌ 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"/>3. Full Words, Not Abbreviations
Section titled “3. Full Words, Not Abbreviations”<!-- ❌ WRONG --><lv_image style_img_recolor="#ff0000"/>
<!-- ✅ CORRECT --><lv_image style_image_recolor="#ff0000"/>4. Dropdown Newlines
Section titled “4. Dropdown Newlines”<!-- ✅ CORRECT - XML entity --><lv_dropdown options="A B 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 layoutsNote: SIZE_CONTENT disables flex wrapping - use explicit width if you need row_wrap.
6. lv_bar value=0 Bug (Upstream)
Section titled “6. lv_bar value=0 Bug (Upstream)”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 runfor (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 itregister_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 widgetlv_obj_t* w = lv_obj_find_by_name(root, "ams_current_tool");
// ✅ CORRECT - test the root, then its descendantsconst 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.
Debugging Checklist
Section titled “Debugging Checklist”When layouts don’t work:
- Label has
style_text_align="center"ANDwidth="100%"? - Parent has
flex_flowset? - Using
style_flex_main_place(NOTflex_align)? - Children have
flex_grow="1"? - Container has
height="100%"? - No mixing absolute positioning with flex?
Visual Debugging
Section titled “Visual Debugging”Add temporary background colors:
<lv_obj style_bg_color="#ff0000" style_bg_opa="100%"> <!-- Check actual size --></lv_obj>Quick Reference
Section titled “Quick Reference”API Functions
Section titled “API Functions”// Component registrationlv_xml_register_component_from_file("A:path/file.xml");
// Subject registrationlv_xml_register_subject(NULL, "name", &subject);
// Event callback registrationlv_xml_register_event_cb(nullptr, "callback_name", function);
// Font registrationlv_xml_register_font(NULL, "font_name", &font);
// Constant registrationlv_xml_register_const(scope, "name", "value");
// Create componentlv_obj_t* obj = lv_xml_create(parent, "component_name", nullptr);
// Find widget by namelv_obj_t* w = lv_obj_find_by_name(parent, "widget_name");Resources
Section titled “Resources”- 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 (seeHELIX_XML_FORK.md§ Clean-room rule). - Quick Reference:
LVGL9_XML_ATTRIBUTES_REFERENCE.md - Example Panels:
ui_xml/bed_mesh_panel.xml(gold standard)