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/ (extracted from LVGL 9.4, MIT licensed) and works with LVGL 9.5.
Last Updated: 2026-02-18
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.xml # Semantic typography│ ├── text_body.xml│ ├── text_small.xml│ └── spinner.xml # Loading indicator├── src/│ ├── main.cpp # Entry point, initialization│ ├── xml_registration.cpp # Component registration│ ├── ui_theme.cpp # Responsive token registration│ ├── ui_nav.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/ ├── 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 tokensui_theme_register_responsive_spacing(); // Sets #space_md, #space_lg, etc.ui_theme_register_responsive_fonts(); // Sets #font_body, etc.
// 4. Register components (order doesn't matter after globals)lv_xml_register_component_from_file("A:ui_xml/icon.xml");lv_xml_register_component_from_file("A:ui_xml/text_heading.xml");lv_xml_register_component_from_file("A:ui_xml/home_panel.xml");// ... etcCore Concepts
Section titled “Core Concepts”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 |
2. Subjects (Reactive Data)
Section titled “2. Subjects (Reactive Data)”Subjects are observable data containers that automatically update bound widgets.
Subject Types
Section titled “Subject Types”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”// ✅ CORRECT - Static or heap-allocatedstatic char status_buffer[128];lv_subject_init_string(&subject, status_buffer, NULL, sizeof(status_buffer), "Initial");
// ❌ WRONG - Stack-allocated (will be destroyed)char buffer[128]; // DANGER: Goes out of scope!lv_subject_init_string(&subject, 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 |
@ | Subject binding | text="@my_subject" | Reactive data on ui_button |
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"/>
<!-- Bind color to subject --><lv_label bind_style_text_color="icon_color" text="#icon_home"/>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.
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>Available Operators:
| Element | Condition |
|---|---|
<bind_flag_if_eq> | subject == ref_value |
<bind_flag_if_not_eq> | subject != ref_value |
<bind_flag_if_gt> | subject > ref_value |
<bind_flag_if_ge> | subject >= ref_value |
<bind_flag_if_lt> | subject < ref_value |
<bind_flag_if_le> | subject <= ref_value |
Supported Flags: hidden, clickable, checkable, scrollable, disabled, ignore_layout, floating
Conditional State Bindings
Section titled “Conditional State Bindings”Control visual states (disabled styling, checked styling):
<lv_button> <!-- Disable when WiFi is off --> <bind_state_if_eq subject="wifi_enabled" state="disabled" ref_value="0"/></lv_button>
<lv_checkbox> <!-- Check when dark mode is on --> <bind_state_if_eq subject="dark_mode" state="checked" ref_value="1"/></lv_checkbox>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>Rule: When using bind_style for reactive visual changes, do NOT set inline style attributes for the properties you want to change reactively.
Applying One Style to Multiple Parts (parts=...)
Section titled “Applying One Style to Multiple Parts (parts=...)”For widgets with several parts that should share the same reactive style — arc background+indicator at the same stroke width, slider track+indicator+knob at the same color — bind_style and every bind_style_if_* variant accept a parts="..." attribute that takes a comma-separated list of part names:
| 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_* elements apply a style only when the subject value matches a comparison condition. Unlike bind_style (which only does exact match), these support all six comparison operators:
| Element | Condition |
|---|---|
<bind_style_if_eq> | subject == ref_value |
<bind_style_if_not_eq> | subject != ref_value |
<bind_style_if_gt> | subject > ref_value |
<bind_style_if_ge> | subject >= ref_value |
<bind_style_if_lt> | subject < ref_value |
<bind_style_if_le> | subject <= ref_value |
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 6 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.
Parse-Time Conditional Hidden Attributes
Section titled “Parse-Time Conditional Hidden Attributes”These attributes hide an element at parse time based on a resolved prop value. Unlike bind_flag_if_* (which is reactive and requires a subject), these evaluate once when the XML is parsed and are useful for component props.
| 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>
<!-- Hidden when mode is NOT "advanced" --><lv_obj hidden_if_prop_not_eq="$mode|advanced"> <text_body text="Advanced mode content"/></lv_obj>These are parse-time only — the hidden state does not change after creation. For reactive visibility that responds 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.
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 |
This means lv_obj acts as a pure layout container by default - no visual styling unless explicitly added.
<!-- 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>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 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>
<!-- 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
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="destructive" text="Delete"/>
<!-- Icon + text --><ui_button variant="primary" icon="check" text="Confirm"/>Variants: primary, secondary, ghost, destructive
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).
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
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 comprehensive responsive design token system with 5 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 height-based: TINY (≤390), SMALL (391-460), MEDIUM (461-550), LARGE (551-700), XLARGE (>700)
- 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>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 |
Theme Colors (C++ API)
Section titled “Theme Colors (C++ API)”// ✅ For theme tokens - handles light/dark mode:lv_color_t bg = ui_theme_get_color("card_bg");lv_color_t ok = ui_theme_get_color("success_color");
// ✅ For literal hex strings:lv_color_t custom = ui_theme_parse_color("#FF4444");
// ❌ WRONG - parse_color doesn't look up tokens:// lv_color_t bg = ui_theme_parse_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 = lv_obj_find_by_name(parent, "temperature_display");
// ❌ WRONG - Index-based (fragile)lv_obj_t* w = lv_obj_get_child(parent, 3);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);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”- LVGL XML Docs: https://docs.lvgl.io/master/details/xml/
- Subject-Observer: https://docs.lvgl.io/master/details/auxiliary-modules/observer/
- Quick Reference:
docs/LVGL9_XML_ATTRIBUTES_REFERENCE.md - Example Panels:
ui_xml/bed_mesh_panel.xml(gold standard)