A declarative XML UI engine for LVGL 9.5+. Loads components, screens, styles, subjects and data bindings from XML at runtime — no code generation, no build step.
License: MIT. See LICENSE.
Home: https://github.com/prestonbrown/helix-xml — issues and roadmap live there.
helix-xml is a hard fork of the XML engine that used to ship inside LVGL core.
LVGL removed the engine from core in v9.5 (7c1e0684f,
2026-01-27) and now sells it as part of LVGL Pro under a proprietary license. This fork was taken
from the last commit before that removal:
commit a15dcbeb56db765db8853261df3013ba037b17fc
describe v9.4.0-358-ga15dcbeb5
date 2026-01-26
LVGL's LICENCE.txt at that tree is plain MIT, and no separate license ever covered the XML
sources while they were in the repository. LVGL has confirmed this in public: "Versions up to 9.4
keep their MIT XML engine forever. MIT can't be revoked, and we wouldn't try."
(announcement, 2026-07-27)
So the code here is MIT, permanently, and modifications by 356C LLC are contributed under MIT to keep it that way.
| Category | Count | Copyright |
|---|---|---|
Unmodified from LVGL @ a15dcbeb5 |
32 | LVGL Kft |
| Derived from LVGL, modified here | 43 | LVGL Kft + 356C LLC |
| Written for helix-xml | 7 | 356C LLC |
Every file carries SPDX headers recording which bucket it falls in. src/libs/expat/ is
byte-identical to the copy LVGL vendored and keeps its own LICENSE.txt.
This fork does not track anything. Post-removal XML development happens in a proprietary LVGL repository that we neither have access to nor may copy from. Every bug here is ours to fix.
Clean-room rule. If a feature is implemented that LVGL Pro also has, it must be built from
LVGL's published documentation (lvgl.io/docs/pro), blog posts, and the observable XML format —
never from LVGL Pro source, a decompiled Editor, or any post-7c1e0684f implementation. Commits
adding such a feature must name the documentation URL and the date it was read.
Features added here that upstream's engine does not have:
| Feature | What it does |
|---|---|
cond= expressions + <subject_expr> |
Full expression language over subjects — arithmetic, comparison, boolean, word forms (a or b gt c). Upstream binds one subject with one comparison operator |
<repeat count=> |
Repeated fragments with $i / ${expr} interpolation; reactive rebuild when count names a subject |
<if cond=> / <else/> |
Structural conditionals — only the matching branch is built |
parts="main,indicator,knob" |
Apply one style bind across several widget parts |
hidden_if_prop_eq / hidden_if_empty |
Prop-driven visibility without allocating a subject |
| Inline element text | <label>Hello</label> sets text and synthesizes a translation tag |
float and color subjects |
Upstream supports only int and string |
<subject name= type= value=> |
Single-tag subject declarations (upstream's tag-per-type form also parses) |
| Authored inline colors | Every inline style_* color written as a global #const or a literal is recorded on its object; lv_xml_obj_has_authored_style() tells bulk recolor code to keep off, and lv_xml_reapply_token_styles() re-resolves the #const ones after lv_xml_set_const(), skipping colors other code has changed since; lv_xml_reapply_style_tokens() does the same for #const colors in named <style>s |
Full reference for the reactive surface — subjects, every binding element, the
expression language, <if>/<repeat>, and the two rules below in context — is
in docs/BINDINGS.md. The two that bite hardest are
repeated here because they fail silently.
Both take the same expression language, and choosing between them is a lifecycle question, not a style one. The wrong choice fails silently.
| Construct | Resolved | Sees |
|---|---|---|
<subject_expr name="X" expr="…"/> |
when the component is registered (lv_xml_component_register_*) |
only subjects that already exist at registration time |
cond="…" on bind_flag_if / bind_state_if / bind_style_if |
when the view is created (lv_xml_create) |
everything above, plus anything registered between registration and creation |
<subject_expr> resolves its operands once, at registration. A subject the
host application creates later — typically in some per-screen init that runs
after all components are registered — does not exist yet, so the expression
does not compile and the derived subject is never registered. Nothing
downstream errors; bindings referencing it simply never fire.
cond= compiles at view-creation instead, so it sees subjects registered any
time before the widget is built. When an operand's registration time is not
under your control, prefer cond= and accept repeating the expression at each
site.
The inverse case is just as real: a view created before the host registers a
subject cannot use cond= against it either. There the fix is to register the
subject earlier, not to change the binding.
bind_flag_if_eq (and its siblings) are two-way: they add the flag when the
subject matches ref_value and remove it when it does not. Several bindings on
one flag or state of one widget compose rather than fight: the property is
applied while any of them holds, whatever order their subjects notify in, so
each independent reason gets its own line.
<!-- Disabled while a job holds the machine, or while an operation is already
running, and enabled only when neither holds. -->
<bind_state_if_eq subject="job_holds_machine" state="disabled" ref_value="1"/>
<bind_state_if_eq subject="operation_in_progress" state="disabled" ref_value="1"/>What that does not give you is the conjunction. Two bindings never AND, so a condition that needs one is a single expression:
<!-- "Hide unless BOTH can_save and dirty" - as two bindings this would hide
when either wanted to. -->
<bind_flag_if cond="can_save and dirty" flag="hidden" invert="true"/>helix-xml is source you compile alongside LVGL — there is no build system here, deliberately. It needs LVGL 9.5 or newer (9.5 is the first release with no XML of its own, so nothing collides).
There is nothing to configure, nothing to install, and no CMake package or exported target to
find. The one CMake file in the repository is tests/CMakeLists.txt, and it exists solely to
build the test suite (see Tests) - it is not a way to consume the library. To use
helix-xml, add the sources below to whatever build you already have.
Sources to compile:
src/xml/*.c
src/xml/parsers/*.c
src/libs/expat/*.c
Include paths:
| Path | For |
|---|---|
<lvgl-root> |
lvgl.h |
<lvgl-root>/src |
<misc/lv_types.h>, <core/lv_observer.h>, <lv_conf_internal.h> … |
<helix-xml-root> |
helix_xml.h |
<helix-xml-root>/src |
internal xml/*.h headers |
wherever your lv_conf.h lives |
LVGL configuration |
<lvgl-root>/src must be visible to anything that includes helix_xml.h, not just to
helix-xml's own translation units — the public headers include LVGL that way. In CMake, put it
on the lvgl target as PUBLIC rather than making it PRIVATE to helix-xml.
Configuration: set LV_USE_XML 1 in your lv_conf.h. Every source here is wrapped in
#if LV_USE_XML, so with it off the whole library compiles away to nothing.
Use: include helix_xml.h after lvgl.h, and helix_xml_private.h after lvgl_private.h.
Then register components and build a view:
#include <lvgl.h>
#include "helix_xml.h"
lv_xml_init();
lv_xml_register_component_from_file("A:ui/my_panel.xml");
lv_obj_t * panel = lv_xml_create(lv_screen_active(), "my_panel", NULL);Loading from files needs an LVGL filesystem driver; lv_xml_register_component_from_data()
takes a string instead if you would rather embed the markup.
The engine used to reach LVGL by relative path (
"../misc/lv_types.h"), which only worked becausesrc/carried symlinks faking LVGL's directory layout — so the tree only built when planted at<x>/lib/helix-xmlbeside<x>/lib/lvgl. Those are gone; the include path above is the whole contract now.
The suite lives in tests/ and builds standalone. It does not need - and must not be pointed
at - the build of whatever application consumes helix-xml. From a bare clone:
cmake -S tests -B build
cmake --build build
ctest --test-dir build --output-on-failure
That is the whole setup. CMake fetches LVGL v9.5.0 with FetchContent (the tag is
HELIX_LVGL_TAG in tests/CMakeLists.txt), compiles helix-xml against it as a static
library, and builds one executable per file in tests/cases/: 402 tests across 21
executables, a couple of seconds end to end.
tests/lv_conf.h is the pinned configuration for the test build, derived from LVGL 9.5.0's
lv_conf_template.h with the switches the XML engine needs turned on. It is deliberately not
synced with any consumer's lv_conf.h - the tests must not inherit whatever the consuming
application happens to configure.
For an offline build, or to bisect an LVGL change:
cmake -S tests -B build -DLVGL_DIR=/path/to/lvgl
LVGL_DIR must be a pristine upstream checkout, and a patched one is rejected at
configure time with an explanation. This is not fussiness: an application that vendors LVGL
as a submodule typically has its own patches applied to it, and such patches can call
app-side symbols that only exist when linking that application. Configure would succeed, the
compile would succeed, and then every test executable would die at link time with a wall of
undefined symbols that says nothing about the real cause. A configure-time probe turns that
into one readable message instead.
If you want a checkout on disk, clone upstream yourself:
git clone --depth 1 --branch v9.5.0 https://github.com/lvgl/lvgl.git /tmp/lvgl-pristine
cmake -S tests -B build -DLVGL_DIR=/tmp/lvgl-pristine
Structure, never pixels. Widget tree shape, names and parent/child relationships, child
counts, label text, flags and states, style property values the XML under test declared,
subject wiring, and captured log output. There is no screenshot or pixel comparison anywhere,
and by rule no assertion may depend on measured geometry or font metrics. That rule is what
lets tests/lv_conf.h differ freely from any consumer's config - colour depth, default font,
enabled widget set and theme are chosen for the test build and are not what any real device
runs, so a geometry assertion would encode the test config and break for reasons unrelated to
the XML engine.
Isolation is total: every test gets a full lv_init() / lv_deinit() cycle. Nothing carries
across a test boundary - not LVGL's heap, not the display or the active screen, not the XML
engine's component, style and subject registries. Test order does not matter.
Helpers, in tests/helpers/:
| Header | For |
|---|---|
helix_test_env.h |
Per-test setup and teardown: lv_init(), a headless 800x480 display and draw buffer, a blank active screen, lv_xml_init(), and the reverse on the way out (in an order that is load-bearing) |
xml_assert.h |
Assertions over an XML-built tree: ASSERT_XML_REGISTERS, XML_CREATE, ASSERT_NAMED, ASSERT_NO_NAMED, ASSERT_CHILD_COUNT, ASSERT_LABEL_TEXT, ASSERT_FLAG, ASSERT_NO_FLAG, ASSERT_STATE, ASSERT_STYLE_INT. There is no ASSERT_WIDTH, and none may be added |
helix_test_pump.h |
Advance LVGL's tick and run its timer handler. Reactive <repeat count="subject"> and <if cond=> defer their rebuild onto an async path, so the order is always mutate, pump, assert - asserting straight after lv_subject_set_int() reads the old tree |
helix_log_capture.h |
Capture LVGL log output so a test can assert on it. Most failure paths in this engine warn and then return success, so the emitted warning is often the only observable difference between "handled and reported" and "silently wrong" - and for the _silent API variants, the absence of a warning is itself the contract |
Drop a .c file into tests/cases/. The glob picks it up and it becomes its own executable
and its own ctest entry - no CMakeLists.txt edit. A case file looks like this:
#include "helpers/helix_test_env.h"
void setUp(void) { helix_test_env_setup(); }
void tearDown(void) { helix_test_env_teardown(); }
static void test_something(void) { /* ... */ }
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_something);
return UNITY_END();
}The runner is Unity, vendored in tests/unity/.
Anything a test reads off disk lives under tests/assets/ and is reached through the
HELIX_TEST_ASSET_DIR compile definition, so no test depends on the shell's working
directory.
.github/workflows/ci.yml runs three jobs on every push and pull request:
| Job | What it does |
|---|---|
test |
The suite on both gcc and clang, CMAKE_BUILD_TYPE=Debug |
sanitize |
The same suite under ASan + UBSan, with detect_leaks=1, abort_on_error=1 and UBSan's halt_on_error=1, so undefined behaviour cannot report itself and let the test pass anyway |
conf-guards |
Compile-checks the library's #if guards against generated variants of tests/lv_conf.h. LV_USE_XML 0 must collapse every source here to an empty translation unit and still produce a linkable archive. LV_USE_TRANSLATION 0 must drop lv_xml_translation.c and leave every call site of it guarded. LV_USE_OBJ_NAME 0 is a negative check: the build must fail, with lv_xml.c's #error "LV_USE_OBJ_NAME is required to use XMLs" - names are how the engine addresses everything it builds, so a silent build without them would be worse than no build |
- Pure C. It must not include or call app-layer C++.
- Excluded from clang-format; match surrounding style by hand.
src/libs/expat/is LVGL's vendored expat, carrying LVGL's#include <lv_conf_internal.h>/#if LV_USE_XMLwrapper.add_lvgl_if.shreapplies that wrapper when re-vendoring.
Further reading, in the HelixScreen repository:
- HELIX_XML_FORK.md — licensing and upstream analysis
- LVGL9_XML_GUIDE.md — syntax guide
- LVGL9_XML_ATTRIBUTES_REFERENCE.md — attribute reference