Declarative framework for LVGL

Hi all,

I’m working on an oscilloscope project, and this repository contains the first major component I built for it: a declarative UI framework on top of LVGL.

The goal was to define interfaces in a structured, predictable way using a small set of explicit models for geometry, style, behavior, and content — all without dynamic allocation or runtime polymorphism.

This is my first C++ project, so I spent a lot of time learning the language while building it, and I’m sure a few Java habits slipped through.

I’m sharing it because I put real engineering effort into it, and someone working with LVGL or embedded UIs might find it useful.

Thanks for taking a look.

Hi,

It’s very interesting! Can you post an example code about how to use it.

With the Editor we are supporting this concept too: build a component library that you can reuse in many projects.

Hello,

Glad to hear you found the project interesting. I’d be happy to walk you through using the system. To make sure I show you something relevant, which part of the system are you looking to extend? Are you wanting to implement a custom widget, work with one of the built-in widgets provided by LVGL, or wrap a display driver?

For anyone following along with this thread, I want to clarify what ComposeUI is and what problems it is designed to solve.

ComposeUI is not a wrapper around LVGL’s API. It is a declarative layer that lets you describe UI structure, geometry, style, and behavior as data rather than imperative mutation.

It is built this way because LVGL already provides a complete object system with its own lifecycle, opinions, and state model. Instead of adding another OOP layer on top, ComposeUI treats LVGL as a system and extends it as a higher‑level paradigm — this keeps responsibilities clean and avoids polluting LVGL’s domain model.

ComposeUI simply gives you a vocabulary for describing the shape of an interface. WidgetAttributes.h defines the subset of LVGL’s domain that ComposeUI models. It is essentially the system’s “Rosetta Stone”: the complete set of attributes that ComposeUI knows how to interpret and translate into LVGL behavior.

The repository works out of the box: you can clone it and run the example project immediately (assuming you meet the dependency requirements found in the README of the project). Once you have done that, the next step should be creating your own widgets through WidgetDefinitions and WidgetInstances. Currently only two widgets are described in ComposeUI: LVGL built-in type “Label”, Custom Widget “Graticules”.

WidgetDefinitions declares the set of WidgetAttributes that you want ComposeUI to associate with LVGL widgets. These definitions populate the WidgetRegistry, which the system uses to understand what widget types exist and how their attributes map onto LVGL widgets.

WidgetInstances associates widget classes and is also used to configure Widget properties. Widget classes are added to the WidgetPool, which serves as a library of widget objects the system can reference. For built-in LVGL types, you can simply use the base Widget class.

Below is the simplest example of modifying the system. For clarity, I’m showing only the .cpp portion here; the corresponding header changes follow naturally from the definitions.

Add WidgetAttributes to the WidgetRegistry:

      namespace UI::Definitions::Widgets {

        Utils::Widget::Attributes my_label_widget;

        void setBuiltIn() {
          // You will want to reference WidgetAttributes to determine what it is that you want
          // to specify here. Relevant Label attributes can be discovered in LVGL's official docs

          // Metadata
          my_label_widget.isCustom = false; // Just being explicit, false = default.
          my_label_widget.type = Utils::Widget::Type::LABEL;
          my_label_widget.role = Utils::Widget::Role::FUNCTIONAL;
          my_label_widget.name = "LVGL forum Example";

          // Layout & appearance.
          my_label_widget.geometry.mode = Utils::Widget::SizingMode::ABSOLUTE;
          my_label_widget.geometry.width = 150;
          my_label_widget.geometry.height = 25;
          my_label_widget.position.alignment = LV_ALIGN_BOTTOM_LEFT;
  
          my_label_widget.label.text = "A Label!";
          my_label_widget.text.textColor = Utils::Widget::Color::BLACK;
          my_label_widget.text.textAlign = LV_TEXT_ALIGN_CENTER;
          my_label_widget.text.letterSpacing = 5;
          my_label_widget.text.lineSpacing = 10;
  
          my_label_widget.background.backgroundColor = Utils::Widget::Color::WHITE;
          my_label_widget.background.backgroundOpacity = 255;
          my_label_widget.border.borderWidth = 2;
          my_label_widget.border.borderColor = Utils::Widget::Color::BLACK;
          my_label_widget.border.borderOpacity = 255;
        }

        void registerWidgets(WidgetRegistry& registry) {
          setBuiltIn();
          registry.registerWidget(Utils::Widget::Type::LABEL, &my_label_widget);
        }
      }

Add a Widget to the WidgetPool:

namespace UI::Instances::Widgets {

  // This is a statically defined widget object. ComposeUI uses compile‑time
  // dispatch, so this isn’t a runtime “instance” in the OOP sense.
  Widget my_label_widget;

  void setDrawEvent() {
    // Only needed when registering callbacks for custom widget types
  }

  void addWidgets(WidgetPool& pool) {
   // For built‑in types, this is effectively a no‑op, but it keeps the pattern consistent
   setDrawEvent(); 

    // Registers the widget
    pool.addWidget(Utils::Widget::Type::LABEL, &my_label_widget);
  }
}

And that’s all you need, at this point you can compile and run. The behavior of the system is fully determined by these two files, which keep the model predictable and easy to reason about.

If you are interested in extending ComposeUI - whether that means adding new built-in widgets, creating custom LVGL widgets, or adapting it to a different display driver - I am happy to put together more examples. Just let me know what direction you are exploring, and I can tailor something specific.

I see now, I was looking for exactly this kind of example.

Does ComposeUI support data bindings?

ComposeUI, in its current form, is static - that’s simply where I paused development while working on other projects. Right now, it just orchestrates putting widgets on the screen based on definitions in the registry and instance pool in setup().

That said, the architecture already lends itself to adding data binding cleanly. The model I am exploring currently looks like this:

Data Binding Model

  • LVGL widget state (or any external state) changes → update the registry
  • Registry changes → trigger a re-render pass
  • Re-render pass → Screen re-applies updated attributes to LVGL widgets

This would form a unidirectional loop, not a two-way binding system. The registry remains the single source of truth, and Screen stays “dumb” - it just reads attributes and applies them.

Runtime Component

To support this, Compose would introduce a small Runtime class that lives inside loop(). I’m still mulling over what other components Runtime may need to work, but the core responsibilities are clear:

  • Capture state changes: Custom widgets already expose callbacks (I’m unsure if built-ins can be hooked as well?) These callbacks would report state changes into the Runtime via something like:
// Internally this might just be a buffer or a queue
runtime.notifyChange(widgetHandle, newValue)
  • Apply changes to the Registry: Runtime would ‘pop’ events and update the corresponding attribute entry:
registry.updateAttribute(widgetHandle, attributeHook, newValue)
  • Trigger the Screen update: Once the registry is updated, the Runtime would ask the Screen to re-apply attributes:
// Screen does not do anything clever, it simply reads the registry, and applies attributes to LVGL widgets
screen.refresh()

Refactoring

Before this lands, I need to clean up some technical debt in Screen. Right now setWidgets() and drawWidgets() are doing too much. Once I break those apart and make Screen idempotent, the reactive update path becomes straightforward.

Looking for LVGL-specific insight

This design assumes that monitoring LVGL state and feeding those changes back into the registry is a reasonable approach. I am still learning LVGL’s patterns, so if you see any pitfalls or have ideas for how to hook into built-in widget events more cleanly, I am happy to learn.

Once the design is solid, I will formalize it into a state diagram, open an issue, and break the work down into steps.

Would you (or anyone reading this) be interested in helping me enhance Compose?

P.S. I propose one-way binding now because I feel that two-way is a separate concern. It will become relevant when Compose is ready to handle non-lvgl sources need to drive widget state:

  • streaming signals
  • updating labels
  • syncing with a remote device
  • etc.

I have not thought enough about how this would need to work yet, so the cleanest architectural enhancement that can be made now is one-way binding. Once that is happy and tested, moving onto two way makes sense. Also, I do still need to start writing unit and slice tests for Compose, so this path seems most reasonable/responsible to me at the moment.