Skip to content

UI Contributor Guide

A hands-on guide for contributing layout fixes and alternate screen layouts to HelixScreen. Whether you’re fixing clipping at 480x320 or building a portrait layout from scratch, this document covers everything you need. XML changes don’t require a rebuild — just relaunch the app.

For C++ internals, threading, and observer patterns, see the Deep Dive References at the bottom.


Build once, then iterate on XML without rebuilding:

Terminal window
# Build the binary
make -j
# Run at any screen size (mock printer, debug logging)
./build/bin/helix-screen --test -vv -s 800x480

The -s WIDTHxHEIGHT flag sets the window size. The --test flag runs against a mock printer so you don’t need real hardware. -vv gives you debug-level logs (helpful when things don’t look right).

SizeCategoryNotes
480x272MicroSmallest supported. Has its own ui_xml/micro/ overrides.
480x320TinyWhere most bugs live.
800x480Standard / MediumThe “default” target. Most common screen.
1024x600LargeWaveshare 7” and similar.
1280x720XLargeLarger desktop-class displays.
1920x480UltrawideBar-style displays. Very wide, very short.
480x800PortraitRotated standard display.
320x1480Tall portraitWaveshare 11.9”. Narrow axis 320 picks a small tier while 1480px of height goes unspent.

Take a screenshot of a specific panel at a specific size:

Terminal window
# Saves to /tmp/ui-screenshot-<name>.png
./scripts/screenshot.sh helix-screen tiny-home home --test -s 480x320

This requires ImageMagick. You can also press S while the app is running for a quick screenshot.

Terminal window
./build/bin/helix-screen --test -vv --layout ultrawide -s 1920x480

home, print-select, controls, filament, settings, advanced

motion, print-status, console, bed-mesh, input-shaper, macros, spoolman, ams

XML changes don’t need a rebuild. Edit any .xml file in ui_xml/, relaunch the app, and see your changes immediately. This makes layout iteration very fast.


There is one tier table and two ladders. Both feed the same seven tiers below; they differ only in which screen dimension they measure. Most tokens resolve from the cramped axis; height tokens resolve from the vertical axis.

TierIndexSuffixAxis rangeTarget DevicesFallback
MICRO0_micro<= 272px480x272Falls back to _tiny
TINY1_tiny273 — 390px480x320Falls back to _small
SMALL2_small391 — 460px480x400, 1920x440Required (core tier)
MEDIUM3_medium461 — 550px800x480Required (core tier)
LARGE4_large551 — 700px1024x600Required (core tier)
XLARGE5_xlarge701 — 1000px1280x720, 1024x768Falls back to _large
XXLARGE6_xxlarge> 1000px1440p, 4KFalls back to _xlarge

Every responsive value needs three core variants: _small, _medium, and _large. The _tiny, _micro, _xlarge, and _xxlarge tiers are optional — define them only when values actually need to differ from their fallback tier.

The index column matters for XML reactive bindings. The ui_breakpoint subject holds the current breakpoint as an integer, and you can use bind_flag_if_* or bind_style_if_* with these values. For example, ref_value="0" means Micro, ref_value="2" means Small.

LadderMeasuresSelects forFunction
Cramped axis (default)min(width, height)Fonts, spacing, widths, column counts, everything axis-neutralresponsive_dimension()
Vertical axisheightHeight tokens — how much room there is to stack thingsresponsive_vertical_dimension()

The cramped axis is the design constraint for anything that has to fit across: on a landscape panel it is usually the height, which is why this was long described as height-based; on a portrait panel it is the width. The vertical axis is the constraint for anything that stacks: a row height on a 1480px-tall panel has no reason to be sized as if the screen were 320px.

On landscape and square displays min(w, h) == h, so the two ladders pick the same tier and nothing differs. Only portrait geometry sees a difference.

Worked example — the 320x1480 Waveshare 11.9”:

Axis valueTier#space_lg#button_height
Cramped320TINY8px
Vertical1480XXLARGE96px

So padding stays tight (the panel really is only 320px wide) while buttons become credible touch targets instead of the 32px the cramped ladder alone would have given them.

Which subject holds which. The ui_breakpoint LVGL subject holds the cramped-axis tier, and only that — every bind_flag_if_eq / bind_style_if_* ref_value in ui_xml/ is written against it. There is no subject for the vertical tier; it exists only in C++, inside token resolution. If you need a structural portrait decision in XML, use ui_breakpoint plus the layout variant directories, not a second tier value.

Background: prestonbrown/helixscreen#1209.

In globals.xml, you define the suffixed variants of each token:

<px name="space_lg_small" value="12"/>
<px name="space_lg_medium" value="16"/>
<px name="space_lg_large" value="20"/>

At startup, theme_manager measures the axis that token follows, picks the matching suffix, and registers the base name (space_lg) pointing to the correct value. So when your XML says style_pad_all="#space_lg", it resolves to 12, 16, or 20 depending on the screen.

One function does the choosing for every token: theme_manager_resolve_px_tokens() (src/ui/theme_manager.cpp). Both the startup registration and the resize path apply its output verbatim, so a token cannot get one tier at boot and another after a rotation.

CRITICAL: Never define the base name in globals.xml

Section titled “CRITICAL: Never define the base name in globals.xml”

This is the most common mistake. Do NOT do this:

<!-- WRONG -- this will silently break responsive overrides -->
<px name="space_lg" value="16"/>
<px name="space_lg_small" value="12"/>
<px name="space_lg_medium" value="16"/>
<px name="space_lg_large" value="20"/>

LVGL ignores duplicate variable registrations. If the base name space_lg is already registered (from the first line), the responsive override from theme_manager is silently discarded. Only define the suffixed variants.

CRITICAL: Responsive tokens only work at the top level of ui_xml/

Section titled “CRITICAL: Responsive tokens only work at the top level of ui_xml/”

Token discovery does not recurse. theme_manager_find_xml_files() skips subdirectories, so only ui_xml/*.xml is ever scanned for suffixed tokens. A <px name="nav_width_small"> declared in ui_xml/components/, ui_xml/portrait/, ui_xml/micro/ or any other subdirectory is never registered, and every #nav_width that reads it silently resolves to nothing.

That is deliberate. Discovery is alphabetical last-wins, so if variant directories were scanned a portrait-only token would shadow its base token globally — including while the standard layout is active — rather than only when that variant is in use.

So declare responsive tokens in ui_xml/globals.xml (or another top-level token file such as ams_tokens.xml) and reference them with #token from the variant file. Non-responsive component-local <consts> are fine inside ui_xml/components/; they resolve through the component’s own scope and never reach discovery.

scripts/check_responsive_token_scope.py fails the build if a suffixed token is declared below the top level. Background: prestonbrown/helixscreen#1211.

Not every font tier is present on every target, and not every present tier is registered at any given moment. Three separate layers decide whether #font_heading resolves to anything:

1. Build time. FONT_TIERS (set per platform in mk/cross.mk, defaulting to all in mk/fonts.mk) decides which faces are linked into the binary at all. CC1 gets micro tiny; AD5M and K1 get medium large; pi32 gets small medium; desktop gets all. From that, HELIX_MAX_FONT_TIER is derived, and theme_manager uses it to tell an expected-missing font (pruned by tier) from an unexpected-missing one (a build bug). A <string name="foo_xxlarge"> naming a face outside the target’s tiers is dead on that target — this is why the hero icon tokens are capped at mdi_icons_64.

2. Startup. AssetManager::register_fonts() registers the faces for the current breakpoint and everything below it, deliberately skipping larger tiers to avoid ~500-800KB of .rodata.

3. Runtime. AssetManager::register_fonts_for_tier() is the re-entrant form. A breakpoint that rises registers the additional tiers; a same-or-lower tier is a no-op. Fonts are never unregistered — live widgets hold pointers into that static .rodata, so the set only ever grows, bounded by HELIX_MAX_FONT_TIER. On a resize, theme_manager_refresh_layout_constants() runs the tier registration first, then re-points the font tokens, then re-runs the switch size presets. That order is load-bearing: re-pointing tokens at tiers whose faces were never registered would silently bounce them back down.

The limitation, stated plainly: updating a token does not restyle widgets that already exist. style_text_font is resolved to a concrete lv_font_t* at parse time and baked into the widget’s style, and the same is true of every px token — this is not new with the font work. So a runtime resize is picked up by anything built afterwards (overlays, modals, print-select cards, later-created switches) but not by the six root panels, which are built eagerly at startup and never rebuilt. Making those follow a resize needs NavigationManager::rebuild_active_views(), which is what hot reload uses. This only matters on desktop SDL and Android; on-device rotation is fixed at startup.


Design tokens are the shared vocabulary for spacing, sizing, and typography. Use them everywhere instead of hardcoded values. They automatically adapt to the current breakpoint.

TokenSmallMediumLargeUse Case
#space_xxs2px3px4pxKeypad rows, compact icon gaps
#space_xs4px5px6pxButton icon+text gaps, dense info
#space_sm6px7px8pxTight layouts, minor separations
#space_md8px10px12pxStandard flex gaps, compact padding
#space_lg12px16px20pxContainer padding, major sections
#space_xl16px20px24pxEmphasis cards, major separations
#space_2xl24px32px40pxToast/overlay offsets

In XML:

<lv_obj style_pad_all="#space_lg" style_pad_gap="#space_md"/>

In C++:

int padding = theme_manager_get_spacing("space_lg");
TokenComponentSmallMediumLarge
#font_heading<text_heading>noto_sans_20noto_sans_26noto_sans_28
#font_body<text_body>noto_sans_14noto_sans_18noto_sans_20
#font_small<text_small>noto_sans_light_12noto_sans_light_16noto_sans_light_18
#font_xs<text_xs>noto_sans_light_10noto_sans_light_12noto_sans_light_14

You almost never need to reference font tokens directly. Use the semantic <text_*> components instead (see Pre-Themed Widgets).

TokenAxisSmallMediumLargePurpose
#button_heightvertical48px52px72pxStandard button height
#button_height_smvertical40px40px40pxSmall buttons (back, icon-only)
#button_height_lgvertical64px70px96pxLarge buttons
#header_heightvertical48px56px60pxPanel header height
#input_heightvertical48px52px56pxText input / dropdown height
#temp_card_heightvertical64px72px80pxTemperature card in print status
#dialog_content_maxvertical260px320px440pxMax height of a modal’s scrollable body
#dialog_content_pinned_maxvertical164px207px272pxSame, plus one pinned block below the scroll area (measured 85% cap − chrome, #1277)
#dialog_content_tall_chrome_maxvertical176px229px282pxSame, plus a second button row with its divider (measured 85% cap − chrome, #1277)
#badge_sizeneutral16px18px20pxStatus badge diameter
#nav_widthhorizontal76px104px132pxNav bar width — see note below
#icon_sizeneutralmdlgxlResponsive icon size string
#spinner_lgneutral48px56px64pxLarge spinner
#spinner_mdneutral24px28px32pxStandard spinner
#spinner_smneutral16px18px20pxSmall spinner
#spinner_xsneutral12px14px16pxCompact spinner

The Axis column says which ladder the token resolves from (see The two ladders). vertical follows the screen height; horizontal and neutral both follow the cramped axis today — the distinction is documentation of intent, not yet two different code paths. All the #space_* tokens are neutral.

Two tokens in that list do not follow the ordinary rules:

  • #nav_width is declared in ui_xml/navigation_bar.xml, not globals.xml, and resolves through its own ladder (helix::nav_width_suffix()) rather than the general one. The nav bar is a full-height vertical strip, so its width tracks the horizontal axis and has an extra ultrawide case.
  • #border_radius is not a globals.xml triplet at all and is deliberately absent from the table. The theme picks a size name (None / Minimal / Subtle / Soft / Rounded / Bold / Pill / Full) and BorderRadiusSizes (include/border_radius_sizes.h) resolves it per breakpoint in C++. Do not add border_radius_* variants to globals.xml. The fixed #border_radius_small / #border_radius_sm (both 4px) are separate — they exist for swatches and badges that always want 4px regardless of theme.

First, classify the axis. Do this before writing the triplet — it decides which ladder the token resolves from:

AxisWhat it coversExamples
verticalHeights, top/bottom padding, vertical maxima — anything sized by how much room there is to stackbutton_height, header_height, dialog_content_max
horizontalWidths, left/right extents, column countsnav_width, field_w_num
axis-neutralApplies to both axes, or to neither (square things, opacity-like scalars)space_*, icon_size, badge_size, the spinner sizes

If in doubt, neutral. Neutral is the default and needs no code change at all.

How the choice is expressed in code:

  • vertical — add the exact base name to VERTICAL_AXIS_TOKENS in src/ui/theme_manager.cpp. That array is the single classification list; theme_manager_token_uses_vertical_axis() reads it, and theme_manager_resolve_px_tokens() is the only thing that calls it, so both registration sites stay in step automatically. Exact names, not a *_height convention — dialog_content_max is a vertical maximum that does not end in _height.
  • horizontal and axis-neutral — nothing to do. Both resolve from the cramped axis, which is correct for widths (in portrait the cramped axis is the width) and is the deliberate compromise for neutrals.

space_* stays neutral on purpose. It feeds pad_top/pad_bottom and pad_left/pad_right alike, so splitting it means classifying every #space_* reference site across ui_xml/ — that reference count, not the declarations, is the cost driver.

Then follow the triplet pattern in globals.xml:

<!-- In globals.xml — define suffixed variants only -->
<px name="my_widget_height_small" value="48"/>
<px name="my_widget_height_medium" value="56"/>
<px name="my_widget_height_large" value="72"/>

Then use the base name in your layout:

<!-- In your panel XML -->
<lv_obj height="#my_widget_height"/>

From C++:

int h = theme_manager_get_spacing("my_widget_height");

The limit: height="content" rows ignore height tokens

Section titled “The limit: height="content" rows ignore height tokens”

A row declared height="content" is sized by its font and its padding. No height token reaches it — not #button_height, not any token you migrate to the vertical ladder.

That covers the whole setting_*_row family (setting_action_row.xml, setting_toggle_row.xml, and friends), and therefore most of Settings. Migrating a token and then finding that Settings did not move is the expected result, not a broken migration. Moving those rows means moving the font/padding tier, which is a different mechanism — see Which font tiers actually exist and prestonbrown/helixscreen#1210.


HelixScreen uses 16 semantic color tokens. These work across light and dark modes automatically — you just reference the token name and the system resolves the right value.

TokenPurpose
#screen_bgMain application background
#overlay_bgSidebar/panel backgrounds
#card_bgCard surfaces
#elevated_bgElevated surfaces (dialogs, inputs)
#borderBorders and dividers
#textPrimary text
#text_mutedSecondary/dimmed text
#text_subtleHint/tertiary text
#primaryPrimary accent color
#secondarySecondary accent
#tertiaryTertiary accent
#infoInfo state (purple in Nord)
#successSuccess state (green)
#warningWarning state (amber)
#dangerError/danger (red)
#focusFocus ring outline

Just reference the token. The system handles light/dark mode for you:

<lv_obj style_bg_color="#card_bg"/>
<text_body style_text_color="#warning" text="High temperature"/>

Themes are defined in config/themes/. Here’s a snippet from nord.json:

{
"name": "Nord",
"dark": {
"screen_bg": "#2e3440",
"card_bg": "#434c5e",
"text": "#eceff4",
"text_muted": "#d8dee9",
"primary": "#88c0d0",
"danger": "#bf616a",
"success": "#a3be8c",
"warning": "#ebcb8b"
},
"light": {
"screen_bg": "#eceff4",
"card_bg": "#ffffff",
"text": "#2e3440",
"text_muted": "#3b4252",
"primary": "#5e81ac",
"danger": "#b23a48",
"success": "#3fa47d",
"warning": "#b08900"
},
"border_radius": 12,
"border_width": 1,
"border_opacity": 40
}
// Token lookup -- correct
lv_color_t bg = theme_manager_get_color("card_bg");
// Hex string parsing -- correct
lv_color_t red = theme_manager_parse_hex_color("#FF0000");
// WRONG -- parse_hex_color does NOT look up tokens
// lv_color_t bad = theme_manager_parse_hex_color("#card_bg");

Define _light and _dark variants. The system auto-discovers them by suffix:

<color name="my_custom_light" value="#E0E0E0"/>
<color name="my_custom_dark" value="#3B4252"/>
<!-- Usage in any XML layout -->
<lv_obj style_bg_color="#my_custom"/>

HelixScreen provides semantic widgets that already have the right colors, fonts, spacing, and responsive behavior baked in. Use these instead of raw LVGL widgets whenever possible — it saves you from manually specifying styles and keeps things consistent.

WidgetFont TokenText StyleUse Case
<text_heading>font_headingMutedSection titles
<text_body>font_bodyPrimaryBody paragraphs
<text_muted>font_bodyMutedSecondary metadata
<text_small>font_smallMutedHelper text
<text_xs>font_xsMutedCompact info, badges
<text_button>font_bodyPrimaryButton labels (centered)

All of these support bind_text="subject_name" for dynamic content and text="static text" for fixed content. You can override the color with style_text_color="#token".

<text_heading text="Temperature"/>
<text_body bind_text="nozzle_temp_display"/>
<text_muted text="Last updated 5 min ago"/>
<text_small text="Firmware v1.2.3"/>

They also accept the text as inline element content instead of a text= attribute — <text_muted>Last updated 5 min ago</text_muted> is equivalent to the text_muted line above, and is translatable by default. See “Inline Text Content” in LVGL9_XML_GUIDE.md for the full rules.

A standard card surface with card_bg background, themed border, and border_radius already applied.

<ui_card width="100%" height="content">
<text_heading text="Temperature"/>
<text_body bind_text="nozzle_temp"/>
</ui_card>

Don’t re-specify style_radius, style_bg_color, or style_border_* — they’re already themed.

Themed button with automatic contrast text color and responsive height.

Variants: primary, secondary, danger, success, warning, tertiary, ghost

<ui_button variant="primary" text="Save"/>
<ui_button variant="danger" text="Delete" icon="trash_can"/>
<ui_button variant="ghost" text="Cancel"/>
<ui_button icon="settings"/> <!-- Icon only, no text -->

Material Design Icons with size and color variants.

Size variants: xs (16px), sm (24px), md (32px), lg (48px), xl (64px).

Color variants: text, muted, primary, secondary, tertiary, success, warning, danger, info, disabled.

<icon src="home" size="lg" variant="primary"/>
<icon src="settings" size="#icon_size" variant="muted"/> <!-- Responsive sizing -->

Responsive loading spinner. Sizes adapt per breakpoint.

<spinner size="lg"/> <!-- 48/56/64px depending on breakpoint -->
<spinner size="md"/> <!-- 24/28/32px -->
<spinner size="sm"/> <!-- 16/18/20px -->

Simple horizontal and vertical dividers with themed colors.

<divider_horizontal/>
<divider_vertical/>

A card with a severity-colored left border. Great for status messages.

Severities: info, success, warning, error

<ui_severity_card severity="warning">
<text_body text="Nozzle temperature is high"/>
</ui_severity_card>

Responsive toggle switch. Sizes scale with the current breakpoint.

<ui_switch size="medium" checked="true"/>

Sizes: tiny, small, medium, large

Theme-aware markdown viewer for rich text content.

<ui_markdown bind_text="release_notes" width="100%"/>

These widgets come pre-themed. Adding redundant style attributes clutters the XML and can conflict with theming:

WidgetAlready Themed (skip these)
ui_cardstyle_radius, style_bg_color, style_border_*
ui_buttonstyle_radius, style_bg_color, height, text color
text_*style_text_font, style_text_color
iconFont selection
divider_*style_bg_color, width/height
ui_markdownAll styling

This section covers the patterns you’ll use most in layout work. For a complete reference, see LVGL9_XML_GUIDE.md and LVGL9_XML_ATTRIBUTES_REFERENCE.md.

Almost everything uses flexbox. The three flows you’ll see:

<lv_obj flex_flow="row"/> <!-- Horizontal: children side by side -->
<lv_obj flex_flow="column"/> <!-- Vertical: children stacked -->
<lv_obj flex_flow="row_wrap"/> <!-- Horizontal, wraps to new rows when full -->

Children with flex_grow expand to fill remaining space in their parent. The parent must have an explicit size (not content).

<lv_obj flex_flow="row" width="100%" height="100%">
<lv_obj flex_grow="3" height="100%"><!-- Left column, 30% --></lv_obj>
<lv_obj flex_grow="7" height="100%"><!-- Right column, 70% --></lv_obj>
</lv_obj>

Unlike CSS flexbox, LVGL needs three properties to fully center items — not two. This trips up almost everyone:

<!-- Fully centered column -->
<lv_obj flex_flow="column"
style_flex_main_place="center"
style_flex_cross_place="center"
style_flex_track_place="center">
<text_body text="I am actually centered"/>
</lv_obj>

Without style_flex_track_place, children with explicit widths stay left-aligned even though the other two properties suggest centering. If something isn’t centering the way you expect, add style_flex_track_place="center" first.

<!-- Gap between children -->
<lv_obj flex_flow="row" style_pad_gap="#space_md">
<ui_button text="A"/>
<ui_button text="B"/>
</lv_obj>
<!-- Internal padding around all edges -->
<lv_obj style_pad_all="#space_lg">
<text_body text="Content with breathing room"/>
</lv_obj>

Show or hide elements based on subject values:

<!-- Hide this widget when status == 0 -->
<lv_obj>
<bind_flag_if_eq subject="status" flag="hidden" ref_value="0"/>
</lv_obj>
<!-- Show only when connected (hide when not equal to 1) -->
<lv_obj>
<bind_flag_if_not_eq subject="connected" flag="hidden" ref_value="1"/>
</lv_obj>

Available operators: bind_flag_if_eq, bind_flag_if_not_eq, bind_flag_if_gt, bind_flag_if_ge, bind_flag_if_lt, bind_flag_if_le.

<lv_button name="save_btn">
<event_cb trigger="clicked" callback="on_save_clicked"/>
<text_body text="Save"/>
</lv_button>

Callbacks are registered in C++. For layout-only work, just keep existing <event_cb> elements in place — don’t remove them or rename them.

When you can’t tell why something is overflowing or misaligned, add a temporary background color to see the actual widget bounds:

<lv_obj style_bg_color="#ff0000" style_bg_opa="100%">
<!-- Now you can see exactly where this container starts and ends -->
</lv_obj>

Remove the debug styles before submitting your PR.

Our theme makes lv_obj a pure layout container by default: transparent background, no border, no padding, sized to content. You don’t need to clear any of these — just use lv_obj as a flexbox wrapper and it stays invisible.

Scrolling is the exception. Our theme does not touch LV_OBJ_FLAG_SCROLLABLE, and LVGL’s own default for it is ON. A wrapper you think of as inert can still absorb drags and pick up a page-scroll gutter. Add scrollable="false" on any container that is not a real scroll region.

WrongRightWhy
width="LV_SIZE_CONTENT"width="content"XML uses string names, not C constants
flex_align="center center"style_flex_main_place="center"flex_align is silently ignored
style_img_recolorstyle_image_recolorFull words, not abbreviations
<lv_dropdown options="A\nB\nC"/>options="A&#10;B&#10;C"Use XML entity for newlines
Hardcoded style_pad_all="12"style_pad_all="#space_lg"Always use design tokens
Hardcoded style_text_font="..."<text_body>Use semantic typography components
style_bg_color="#2e3440"style_bg_color="#screen_bg"Use color tokens, not hex values
width="#overlay_width_destination"(no width attribute)Overlay width is resolved at push time
<lv_obj> layout wrapper with no scrollable attribute<lv_obj scrollable="false">LVGL’s scrollable default is ON and our theme does not override it, so the wrapper absorbs drags and can get a page-scroll gutter
height="100%" inside a height="content" parentheight="content"The two depend on each other and both collapse to zero
flex_grow on children of a *_wrap containerpercentage widths + style_flex_main_place="space_between"Grow items contribute zero base size, so nothing ever wraps
<style flex_flow="row"/><style layout="flex" flex_flow="row"/>flex_flow in a style is inert without layout="flex"

The last three are explained in full, with the ctl geom signatures that identify them, in LVGL9_XML_GUIDE.md under “Flex Layout”.

An overlay is one of two things, and the difference is visible against the nav dock:

widthlooks like
Destinationscreen - navflush against the dock; a place you park
Transient layerscreen - nav - space_lga gap showing the dimmed backdrop; you’ll go back

Settings is a destination, so everything you reach inside Settings is flush — Settings › Network is a sub-screen of Settings, not a layer over it. AMS and Print Status are destinations too: people live on those screens. Console, Bed Mesh, Motion and the calibration panels are transient layers — tools you open and return from.

Which one you get is not yours to choose in XML. It depends on how the user reached the overlay, and the same overlay can be reached both ways: Fan Control opened from Controls is a transient layer, and opened from Settings › Fans it’s a drill-down. NavigationManager::push_overlay() resolves it against the live navigation stack on every push, so just leave width off:

<view name="my_overlay" extends="overlay_panel" title="My Overlay" title_tag="My Overlay">

Two things you can declare, both in C++:

  • A long-dwell screen that should be a destination from every entry point: override IPanelLifecycle::is_destination() to return true. See include/ui_panel_ams.h.
  • A deliberately odd width that is neither class — widget_catalog_overlay is width="70%" so the home grid stays visible behind it. Set the width in XML and call NavigationManager::set_overlay_width_unmanaged() once after creating the widget, or push will overwrite it.

scripts/check_overlay_width.py fails the build if XML names a width class. Background: include/overlay_class.h and prestonbrown/helixscreen#1178.


HelixScreen supports layout-specific XML overrides so you can rearrange panels for different screen shapes without touching the standard layouts.

LayoutDetectionExample Screens
standardNormal landscape (4:3 to 16:9)800x480, 1024x600, 1280x720
ultrawideAspect ratio > 2.5:11920x480, 1920x400
portraitAspect ratio < 0.8:1480x800, 600x1024
microMin dimension <= 272, landscape480x272
micro_portraitMin dimension <= 272, portrait272x480
tinyMax dimension <= 480, landscape480x320, 320x240
tiny_portraitMax dimension <= 480, portrait320x480, 240x320

Detection lives in LayoutManager::detect() (src/layout_manager.cpp). Portrait sub-classes (micro_portrait, tiny_portrait) fall back through the shared portrait/ layer before the standard layout — see variant_chain().

Force a layout with --layout ultrawide on the command line, or set display.layout in settings.json.

ui_xml/
globals.xml <-- Shared by ALL layouts (never override this)
home_panel.xml <-- Standard home panel
controls_panel.xml <-- Standard controls panel
... <-- 226 XML files total
micro/ <-- Micro (480x272) overrides (4 files)
controls_panel.xml
header_bar.xml
...
portrait/ <-- Portrait overrides (app_layout.xml, navigation_bar.xml)
micro_portrait/ <-- Micro-portrait overrides (dir present, empty)
ultrawide/ <-- Does NOT exist yet (no overrides created)
tiny/, tiny_portrait/ <-- Do NOT exist yet

If ui_xml/<layout>/<panel>.xml exists, it’s used instead of ui_xml/<panel>.xml. Otherwise the standard version is loaded. You only need to override the panels that actually need different layouts — everything else falls through automatically.

  1. Copy the standard layout as a starting point:

    Terminal window
    cp ui_xml/controls_panel.xml ui_xml/ultrawide/controls_panel.xml
  2. Edit the copy for the target screen shape.

  3. Test it:

    Terminal window
    ./build/bin/helix-screen --test -vv --layout ultrawide -s 1920x480
  4. No rebuild needed. XML loads at runtime.

When creating a layout override, you’re rearranging the same content for a different screen shape. The C++ code still expects certain widgets, bindings, and callbacks to exist.

  1. Keep all named widgets that C++ looks up via lv_obj_find_by_name(). Search the panel’s .cpp file to find required names:

    Terminal window
    grep lv_obj_find_by_name src/ui/panels/controls_panel.cpp
  2. Keep all subject bindings (bind_text, bind_value, bind_flag_if_*, etc.). These connect the UI to live data.

  3. Keep all event callbacks (<event_cb> elements). These wire up button presses and interactions.

  4. Use design tokens for all colors, spacing, and fonts. No hardcoded values.

  5. Don’t change existing values in globals.xml. They’re shared across all layouts. Adding a new suffixed token there is correct, and is the only place a variant-specific token can live — token discovery never recurses into ui_xml/ subdirectories, so a <px name="foo_small"> declared next to your override is silently never registered (see section 2).

You’re free to rearrange the visual hierarchy, change flex directions, adjust sizes, hide optional decorative elements, or add new layout containers. Just preserve the functional widgets.

Ultrawide (1920x480): Tons of horizontal space, very little vertical. Favor flex_flow="row" to spread content across columns. Aim for everything visible at once with no scrolling. Think “dashboard with columns” — put related info side by side instead of stacking it.

Portrait (480x800): Lots of vertical space, narrow width. Content stacks naturally with flex_flow="column". The navbar probably needs to move to the bottom of the screen. Consider overriding navigation_bar.xml and app_layout.xml to change the overall chrome.

Tiny (480x320): Very limited in both directions. Reduce information density, use bigger touch targets (48px minimum), show fewer labels. Hide optional elements with conditional visibility or just remove decorative content.

Start with the panels that matter most:

PriorityPanelWhy
Highhome_panel.xmlFirst thing users see
Highapp_layout.xmlOverall chrome (navbar + content area)
Highnavigation_bar.xmlNav position/orientation differs per layout
Mediumcontrols_panel.xmlMultiple cards that benefit from rearranging
Mediumprint_status_panel.xmlImportant during active prints
Mediumsettings_panel.xmlCompact 6-row category menu; sub-panels may benefit from multi-column
LowOverlaysUsually modal dialogs that adapt reasonably well

Responsive Setting Rows (no micro/ overrides)

Section titled “Responsive Setting Rows (no micro/ overrides)”

The setting row components (setting_toggle_row, setting_slider_row, setting_dropdown_row, setting_action_row, setting_section_header) handle the Micro breakpoint responsively within a single XML file. There are no micro/ directory overrides for settings — do not create them.

The pattern used by all setting rows:

  1. Two bind_style_if_* for padding — compact padding on Micro (breakpoint 0), standard padding on Tiny and above (breakpoint >= 1):
<styles>
<style name="pad_standard" pad_left="#space_lg" pad_right="#space_lg"
pad_top="#space_lg" pad_bottom="#space_lg" pad_gap="#space_sm"/>
<style name="pad_micro" pad_left="#space_md" pad_right="#space_md"
pad_top="#space_lg" pad_bottom="#space_lg" pad_gap="#space_sm"/>
</styles>
<view ...>
<bind_style_if_eq name="pad_micro" subject="ui_breakpoint" ref_value="0"/>
<bind_style_if_ge name="pad_standard" subject="ui_breakpoint" ref_value="1"/>
  1. Description text hidden on small screens — the description label is hidden on Micro and Tiny (breakpoint < 2) via bind_flag_if_lt:
<text_small name="description" text="$description">
<bind_flag_if_lt subject="ui_breakpoint" flag="hidden" ref_value="2"/>
</text_small>
  1. Info icon for small screens — an info icon appears on Micro/Tiny when a description prop is non-empty. Uses the parse-time hidden_if_empty attribute so it never renders when there is no description, and bind_flag_if_gt to hide it on Medium and above:
<lv_obj name="info_btn" clickable="true" hidden_if_empty="$description">
<bind_flag_if_gt subject="ui_breakpoint" flag="hidden" ref_value="1"/>
<icon src="info_outline" size="sm" variant="muted"/>
<event_cb trigger="clicked" callback="on_setting_info_clicked"/>
</lv_obj>

Tapping the info icon toggles the description label’s visibility inline.

This same responsive pattern should be used for any new setting row components. The convention is: make the component responsive internally rather than creating a micro/ override file.


This is where to start if you want to contribute. Issues are organized by severity and area. All observations below are primarily at 480x320 unless noted otherwise.

These cut across many screens and are high-value fixes:

  • Numeric keypad overlay doesn’t fit vertically at 480x320 — bottom rows are cut off. This affects every panel that uses the keypad for numeric input.
  • Many modals don’t respect viewport height — content clips at top and bottom on small screens.
  • Navbar icons are clipped at 480x320, outlines overlap, and click targets may overlap each other.
  • Temperature labels collide with values on the controls and filament panels.
  • Settings dropdown menus are hardcoded too wide for small screens.

These are the most impactful fixes:

  • Print Select list view — The most broken screen. Padding is wrong, row sizing is off, horizontal overflow everywhere.
  • Print Status overlay — Action buttons, temperature cards, and metadata are all fighting for space. Nothing fits.
  • Filament panel — Multi-filament card is invisible, material buttons crush the operations section.

Usable but clearly broken in places:

  • Controls panel — Position card labels overlap the header, Z-offset value wraps, cooling section overflows, quick actions are clipped.
  • Print Select card view — Metadata area is too tall, squeezing the file thumbnails.
  • PID Calibration — Chips are clipped, text doesn’t wrap, slider padding is off, values wrap awkwardly.

Things that work but could look better:

  • Home panel — Tip text is borderline too large, status icon temperature padding is slightly off.
  • Print File Detail — Pre-print options are cramped when toggles are present.
  • Z-Offset Calibration — Slightly too tall for the viewport, could use scroll.
  • Spoolman — Too much padding, wasted space.
  • Print History list — Filter fields are too wide.

These panels work well and can serve as reference for how to do things right:

  • Motion overlay
  • Advanced settings
  • Settings panel (except dropdown widths)
  • Theme view and edit
  • Print History dashboard
  • Not started — ui_xml/ultrawide/ does not exist yet, so ultrawide screens currently fall through to the standard layout.
  • Wide open for contributions. Force it with --layout ultrawide -s 1920x480.
  • ui_xml/portrait/ exists with two overrides (app_layout.xml, navigation_bar.xml).
  • ui_xml/micro/ exists with four overrides (controls_panel.xml, header_bar.xml, theme_editor_overlay.xml, theme_preview_overlay.xml).
  • ui_xml/micro_portrait/ exists as a directory but has no overrides yet.
  • tiny/, tiny_portrait/ do not exist yet. Wide open for contributions.

Terminal window
# Micro (480x272)
./scripts/screenshot.sh helix-screen micro-home home --test -s 480x272
# Tiny (480x320)
./scripts/screenshot.sh helix-screen tiny-home home --test -s 480x320
# Standard/Medium (800x480)
./scripts/screenshot.sh helix-screen medium-home home --test -s 800x480
# Large (1024x600)
./scripts/screenshot.sh helix-screen large-home home --test -s 1024x600
# XLarge (1280x720)
./scripts/screenshot.sh helix-screen xlarge-home home --test -s 1280x720
# Ultrawide (1920x480)
./scripts/screenshot.sh helix-screen ultrawide-home home --test --layout ultrawide -s 1920x480
# Portrait (480x800)
./scripts/screenshot.sh helix-screen portrait-home home --test --layout portrait -s 480x800
# Tall portrait (320x1480) -- the narrow-axis case
./scripts/screenshot.sh helix-screen tall-portrait-home home --test --layout portrait -s 320x1480

Screenshots save to /tmp/ui-screenshot-<name>.png.

Set HELIX_SCREENSHOT_DISPLAY=0 to prevent the app from opening a visible display window (useful for CI or batch screenshots).

Replace home with any panel or overlay name: controls, filament, settings, print-status, motion, etc.

Two rules, both required — they address different crashes.

Rule 1 — never destroy a container from inside an event callback on its own child (issue #80). The child widget is still on the call stack; deleting its parent synchronously causes use-after-free. Defer the rebuild to the next tick.

Rule 2 — never call synchronous widget deletion inside a deferred callback (issue #776). lv_obj_clean(), lv_obj_del(), and helix::ui::safe_delete() all run synchronously. Multiple sync deletions in the same UpdateQueue::process_pending() batch corrupt LVGL’s event linked list → SIGSEGV in lv_event_mark_deleted. ui_queue_update, lifetime_.defer, tok.defer, and observer callbacks all share that batch — the deferral alone is not enough.

Use the safe replacement — it reparents children to lv_layer_top() and schedules them for lv_obj_delete_async(), which runs outside our UpdateQueue batch:

// ❌ BAD (#80): swatch click handler destroys its own parent synchronously
void handle_color_selected(...) {
lv_obj_clean(container);
rebuild(container);
}
// ❌ STILL BAD (#776): deferred, but lv_obj_clean is still a sync batch deletion
void handle_color_selected(...) {
ui_queue_update([this]() {
lv_obj_clean(container); // Corrupts event list under load
rebuild(container);
});
}
// ✅ CORRECT: defer the rebuild AND use safe_clean_children
void handle_color_selected(...) {
lifetime_.defer([this]() {
helix::ui::safe_clean_children(container);
rebuild(container);
});
}

Replacements table:

❌ Banned in deferred callbacks✅ Use instead
safe_delete(ptr)safe_delete_deferred(ptr)
lv_obj_delete(obj) / lv_obj_del(obj)lv_obj_delete_async(obj)
lv_obj_clean(container)helix::ui::safe_clean_children(container)

See include/ui_utils.h and THREADING.md §3 for the full rationale.

Before submitting, verify these for every change:

  • No text clipping or overflow at the target size
  • Touch targets are large enough (48px minimum recommended)
  • Design tokens used throughout (no hardcoded pixel values, colors, or fonts)
  • All subject bindings preserved from the standard layout
  • All event callbacks preserved
  • All named widgets still present (check with grep lv_obj_find_by_name in the C++ source)
  • Standard layout still works at 800x480 (no regression)

PRs are welcome. To make review smooth:

  • Include before/after screenshots at the target resolution.
  • Test at the target size and at standard (800x480) to verify no regression.
  • If you created a layout override, mention which named widgets you found in the C++ source so reviewers can verify coverage.
  • Keep changes focused. One panel per PR is easier to review than five.

For the full details on any of these topics, see the dedicated docs:

DocumentWhat It Covers
LVGL9_XML_GUIDE.mdFull XML system guide — subjects, events, component creation, implementation patterns
LVGL9_XML_ATTRIBUTES_REFERENCE.mdQuick-lookup cheatsheet for every XML attribute
DEVELOPER_QUICK_REFERENCE.mdC++ patterns — observer factory, threading, class structures
ARCHITECTURE.mdSystem architecture and high-level design decisions

For most layout and styling work, you shouldn’t need these. But if you’re adding new components, wiring up new subjects, or debugging why a binding isn’t working, that’s where the answers live.