Skip to content

LVGL9 XML Guide

Comprehensive guide to the declarative XML UI system with reactive data binding, based on practical experience building the HelixScreen UI. The XML engine lives in lib/helix-xml/ (extracted from LVGL 9.4, MIT licensed) and works with LVGL 9.5.

Last Updated: 2026-02-18


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

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

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

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

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

helixscreen/
├── ui_xml/ # 60+ XML component definitions
│ ├── globals.xml # Theme constants, responsive tokens
│ ├── app_layout.xml # Root: navbar + content area
│ ├── navigation_bar.xml # Vertical nav buttons
│ ├── *_panel.xml # Main panels (home, controls, motion, etc.)
│ ├── *_overlay.xml # Modal overlays
│ ├── *_modal.xml # Dialog modals
│ ├── icon.xml # Icon custom widget
│ ├── text_heading.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 cheatsheet

Registration Flow (main.cpp + xml_registration.cpp)

Section titled “Registration Flow (main.cpp + xml_registration.cpp)”
// 1. Register fonts
lv_xml_register_font(NULL, "montserrat_16", &lv_font_montserrat_16);
lv_xml_register_font(NULL, "montserrat_20", &lv_font_montserrat_20);
// 2. Register globals FIRST (constants must be available)
lv_xml_register_component_from_file("A:ui_xml/globals.xml");
// 3. Register responsive spacing tokens
ui_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");
// ... etc

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

<component>
<!-- Optional: Component API (properties from parent) -->
<api>
<prop name="text" type="string" default="Click me"/>
<prop name="enabled" type="bool" default="true"/>
</api>
<!-- Optional: Local constants -->
<consts>
<px name="button_size" value="36"/>
</consts>
<!-- Optional: Local styles (NO style_ prefix!) -->
<styles>
<style name="style_base" bg_color="0x333" text_color="0xfff"/>
</styles>
<!-- The actual UI definition -->
<view extends="lv_button" width="#button_size">
<!-- Use API props with $ prefix -->
<lv_label text="$text" align="center"/>
<style name="style_base"/>
</view>
</component>
TypeDescriptionExample
stringText valuesdefault="Hello"
intInteger numbersdefault="42"
booltrue/falsedefault="true"
colorHex colorsdefault="0xff4444"
subjectSubject referencesFor data binding

Subjects are observable data containers that automatically update bound widgets.

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

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

// ✅ CORRECT - Static or heap-allocated
static 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, ...);

LVGL XML uses prefix sigils to distinguish different value types:

SigilMeaningExampleContext
#Design token / conststyle_pad_all="#space_md"Spacing, colors, sizes
$Component proptext="$primary_text"Inside component templates
@Subject bindingtext="@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.

<!-- 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) resolve bind_text directly as a subject name. The @ prefix convention is specific to ui_button, which needs to disambiguate between literal button labels and subject references.

<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:

ElementCondition
<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

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.

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 nameLV_PART_*
mainLV_PART_MAIN
scrollbarLV_PART_SCROLLBAR
indicatorLV_PART_INDICATOR
knobLV_PART_KNOB
selectedLV_PART_SELECTED
itemsLV_PART_ITEMS
cursorLV_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:

ElementCondition
<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.

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.

AttributeBehavior
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.

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

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

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

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

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

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


Our theme system sets these defaults on all lv_obj containers:

PropertyDefault ValueNotes
widthcontentShrinks to content size
heightcontentShrinks to content size
border_width0No border by default
bg_opa0Transparent background
pad_all0No 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>

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

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

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

Section titled “Flex Alignment (Three Properties — You Need ALL THREE to Center!)”
PropertyControlsCSS Equivalent
style_flex_main_placeMain axis distribution (vertical in column)justify-content
style_flex_cross_placeCross axis alignment (horizontal in column)align-items
style_flex_track_placeTrack alignment — required to center items with explicit widthsalign-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"/>
ValueBehavior
startBeginning (left/top)
centerCentered
endEnd (right/bottom)
space_evenlyEqual space around all
space_aroundEqual space, double at edges
space_betweenNo edge space, even gaps

Children with flex_grow expand to fill remaining space:

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

When using flex_grow, the parent MUST have explicit height:

<!-- ✅ Parent needs height="100%" for flex_grow to work -->
<lv_obj flex_flow="row" height="100%">
<lv_obj flex_grow="3" height="100%">Left</lv_obj>
<lv_obj flex_grow="7" height="100%">Right</lv_obj>
</lv_obj>
<lv_obj flex_flow="row"
style_pad_column="10" <!-- Horizontal gap -->
style_pad_row="5"> <!-- Vertical gap (if wrapping) -->
<!-- Text: BOTH required -->
<lv_label text="Centered" style_text_align="center" width="100%"/>
<!-- Flex centering -->
<lv_obj flex_flow="column" height="100%"
style_flex_main_place="center" style_flex_cross_place="center">
<lv_label text="Centered"/>
</lv_obj>
<!-- Single child: use align, NOT flex (flex conflicts with align) -->
<lv_obj width="100%" height="100%">
<lv_obj align="center">Perfectly centered</lv_obj>
</lv_obj>

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

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

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

Variants: primary, secondary, accent, disabled, warning

Adding Icons:

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

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

<!-- ✅ CORRECT - Semantic components -->
<text_heading text="WiFi"/>
<text_body text="Connected"/>
<text_small text="192.168.1.150"/>
<!-- ❌ WRONG - Hardcoded fonts -->
<lv_label text="WiFi" style_text_font="montserrat_20"/>
ComponentPurposeResponsive Sizing
<text_heading>Section titles20px / 26px / 28px
<text_body>Primary content14px / 18px / 20px
<text_small>Captions12px / 16px / 18px

All support bind_text, align, style_text_color, etc.

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

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

Container with card styling from theme_core.

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

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

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

<!-- Primary action button -->
<ui_button variant="primary" text="Save"/>
<!-- Secondary button -->
<ui_button variant="secondary" text="Cancel"/>
<!-- Ghost (transparent) for toolbars -->
<ui_button variant="ghost" icon="settings"/>
<!-- Destructive action -->
<ui_button variant="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).

Visual separators with theme-aware colors.

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

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

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

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

Attributes:

AttributeTypeDescription
bind_textstringBinds to a string subject for dynamic markdown content
textstringSets static markdown content directly
namestringWidget name for lv_obj_find_by_name() lookup
widthsizeWidth (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:

ElementFont TokenColor Token
Body textfont_bodytext
H1font_headingprimary
H2font_headingsecondary
H3-H4font_bodytext
H5-H6font_smalltext_muted
Inline codefont_smalltext on elevated_bg
Code blocksfont_smalltext on elevated_bg
Blockquote borderprimary
Horizontal ruletext_muted

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

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

Usage Pattern — Scrollable Container:

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

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

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

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

Setting Content from C++:

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

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

Registration:

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

Limitations and Gotchas:

  • No image/link support — markdown images and hyperlinks are not rendered
  • LVGL spangroups do not support per-span background styles, so inline code background color (code_bg_color) has no visible effect
  • Theme changes at runtime do not automatically re-style existing markdown widgets (the style is applied at creation time)
  • The text attribute in XML does not support literal newlines; use \n for line breaks in static content
  • When using bind_text, the observer does not use ObserverGuard — the observer is cleaned up automatically when the widget is deleted via LVGL’s built-in observer-object tracking
WidgetDon’t Specify (Built-in)
ui_cardstyle_radius, style_bg_color, style_border_*
ui_buttonstyle_radius, style_bg_color, style_height, text color
text_*style_text_font, style_text_color
iconFont selection
divider_*style_bg_color, width/height (1px)
ui_markdownAll styling (theme-aware fonts, colors, spacing)

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_xxs through #space_2xl — always use tokens, never hardcoded pixels
  • Fonts: Use <text_heading>, <text_body>, <text_small>, <text_xs> components
  • Colors: Use #token_name in XML (e.g., style_bg_color="#card_bg")

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

<styles>
<!-- ✅ CORRECT - No prefix in style definitions -->
<style name="style_button" bg_color="0x111" radius="8" pad_all="12"/>
<!-- ❌ WRONG - style_ prefix doesn't work here -->
<style name="bad_style" style_bg_color="0x111"/>
</styles>
<!-- By name -->
<lv_button>
<style name="style_button"/>
</lv_button>
<!-- With state selector -->
<lv_button>
<style name="style_base"/>
<style name="style_pressed" selector="pressed"/>
</lv_button>
<!-- Inline (USE style_ prefix) -->
<lv_button style_bg_color="0x111" style_radius="8"/>

Many widgets have styleable parts:

<!-- Style slider knob separately -->
<lv_slider style_bg_color="#333333"
style_bg_color:indicator="#primary_color"
style_bg_color:knob="#ffffff"/>
<!-- Hide spinner background track -->
<lv_spinner style_arc_opa:main="0"/>
PartWidgets
mainAll (background)
indicatorslider, bar, arc, spinner
knobslider, arc
itemsdropdown, roller
scrollbarScrollable containers
// ✅ 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!

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

Step 1: Declare in XML

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

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

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

Step 3: Implement callback

static void on_click_cb(lv_event_t* e) {
spdlog::info("Button clicked!");
}
// Or use lambda
lv_xml_register_event_cb(nullptr, "on_slider_changed", [](lv_event_t* e) {
lv_obj_t* slider = lv_event_get_current_target(e);
int value = lv_slider_get_value(slider);
spdlog::info("Slider: {}", value);
});
TriggerWhen Fired
clickedButton click (press + release)
value_changedSlider, dropdown, switch
pressedObject pressed down
releasedObject released
long_pressedLong press detected
focusedObject gains focus
readyText area complete

ui_xml/example_panel.xml:

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

include/example_panel.h:

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

src/example_panel.cpp:

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

In main.cpp:

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

<lv_label name="temperature_display" bind_text="temp"/>
// ✅ CORRECT - Name-based (resilient)
lv_obj_t* w = lv_obj_find_by_name(parent, "temperature_display");
// ❌ WRONG - Index-based (fragile)
lv_obj_t* w = lv_obj_get_child(parent, 3);
<!-- ❌ WRONG - Component not findable -->
<controls_panel/>
<!-- ✅ CORRECT - Explicit name -->
<controls_panel name="controls_panel"/>

Widgets must have names when:

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

Widgets can safely omit names:

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

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

PatternWhy BannedAlternative
lv_obj_add_event_cb()Tight couplingXML <event_cb>
lv_label_set_text()Bypasses bindingbind_text subject
lv_obj_add_flag(HIDDEN)Visibility is UI<bind_flag_if_eq>
lv_obj_set_style_*()Styling in XMLDesign tokens
  1. LV_EVENT_DELETE cleanup
  2. Widget pool recycling (virtual scroll)
  3. Chart data points
  4. Animations
  5. One-time setup() widget lookup

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

5. Complex Layouts Need lv_obj_update_layout()

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

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

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

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

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

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

When layouts don’t work:

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

Add temporary background colors:

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

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