RunLVGL - Building a Watch App GUI with LVGLļ
RunLVGL is the Run activity app with its GUI process rewritten on LVGL instead of TouchGFX. It has the same screens, fonts, icons, menus and workout flow as Run, records the same FIT activities, and runs on the same kernel with no kernel changes. It exists so that developers who prefer LVGL can see a complete, shipping-grade app built with it, and use its port and simulator for their own apps.
What Youāll Learnļ
What the kernel gives a GUI process, and why the toolkit is the appās choice
How the SDKās LVGL port turns LVGLās rendering into kernel frames, ticks and buttons
How the RunLVGL GUI is organised: model, screens, widgets, theme and assets
How to build for the watch on Windows or Linux, and run the PC simulator
Where the memory goes in an LVGL GUI process, and how to keep it small
This tutorial assumes you have read the earlier tutorials, in particular HelloWorld for the service/GUI split and the Sensors tutorial for the sensor layer the service uses. The service half of RunLVGL is a copy of Runās and is not covered again here; see Running - Fitness Tracking.
Getting Startedļ
Prerequisitesļ
Everything in the toolchain setup, plus the LVGL submodule. LVGL is a git submodule of the SDK, pinned to a release tag (v9.5.0), so check it out once:
cd $UNA_SDK
git submodule update --init ThirdParty/lvgl
The converted fonts and images are committed, so building the app needs neither Node nor Python. Regenerating them does; see Assets.
Building for the Watchļ
The same recipe as every other app:
cd $UNA_SDK/Examples/Apps/RunLVGL/Software/Apps/RunLVGL-CMake
mkdir build && cd build
cmake ..
make
On Windows without Docker, use Ninja and the ST toolchain from STM32CubeIDE on PATH;
the CMake generator is the only difference (cmake -G Ninja ..). The final copy step
into the appās Output/ directory uses a glob that fails under CMakeās -E copy; the
.uapp is still produced in the build directory and can be copied by hand.
Install RunLVGL_<version>.uapp into D:\Apps\RunLVGL\ on the watch with
Utilities/Scripts/Update-Watch-Apps.ps1, or by copying it there. The app appears in
the launcher as RunLVGL.
Running on the Simulatorļ
RunLVGL has a PC simulator that runs the real service and GUI processes against the SDKās mock kernel, with the display in an SDL2 window. Unlike the TouchGFX simulators, it is a plain CMake project and builds on Windows and Linux.
It needs a host C++ compiler, CMake, a generator (Ninja, or Visual Studio on Windows) and SDL2, and no IDE:
Linux, or WSL on Windows: GCC, CMake, Ninja and the SDL2 development package.
Windows: the MSVC compiler, which comes with the free Build Tools for Visual Studio (the āDesktop development with C++ā workload) as well as with the Visual Studio IDE; either is enough. The workloadās āC++ CMake tools for Windowsā component brings CMake and Ninja, and a developer command prompt puts them on
PATH; both can also be installed on their own. SDL2 comes from a development package that CMake can find (vcpkg, for example) or, with TouchGFX Designer installed, from the 32-bit copy it ships. The buildās architecture must match the SDL2 it links, and it is chosen by the developer environment with the Ninja generator (x86 or x64 prompt) and by-Awith a Visual Studio generator (-A Win32or-A x64; Ninja does not take-A). With the TouchGFX copy the build is 32-bit, as in the commands below; with an installed 64-bit SDL2 pick the 64-bit option instead. MinGW is untested.
Windows, in an x86 Native Tools Command Prompt for VS (a cmd window with the
32-bit MSVC environment loaded; any shell after vcvarsall.bat x86 is the same):
set UNA_SDK=C:/path/to/una-sdk
cd /d %UNA_SDK%\Examples\Apps\RunLVGL\Software\Apps\LVGL-GUI\simulator
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build
build\bin\RunLVGLSimulator.exe
The Visual Studio generator finds MSVC itself, so it works from any shell, and it also
writes a solution to open in the IDE ("Visual Studio 18 2026" needs CMake 4.2 or newer,
"Visual Studio 17 2022" works with the 3.21 minimum):
$env:UNA_SDK = "C:/path/to/una-sdk"
cd $env:UNA_SDK\Examples\Apps\RunLVGL\Software\Apps\LVGL-GUI\simulator
cmake -S . -B build -G "Visual Studio 18 2026" -A Win32
cmake --build build --config Debug
.\build\bin\RunLVGLSimulator.exe
Linux, or WSL (Debian/Ubuntu):
sudo apt-get install build-essential cmake ninja-build libsdl2-dev
export UNA_SDK=/path/to/una-sdk
cd $UNA_SDK/Examples/Apps/RunLVGL/Software/Apps/LVGL-GUI/simulator
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build
(cd build/bin && ./RunLVGLSimulator)
Keys are the watch buttons: 1 = L1 (up), 2 = L2 (down), 3 = R1 (select),
4 = R2 (back). Holding a key is a press, releasing it a release, and a hold shorter
than 500 ms is also a click, the same events the kernel emits. 5 raises a simulated
wrist-motion event and Esc closes the app. Run the executable from build/bin: the
mock file system is ../../../../../Output relative to the working directory, which
resolves to Software/Output, the simulatorās sandbox (ignored by git); recorded
activities land there.
The simulated GPS gets a fix after four seconds and the heart rate starts a few seconds
later; both are configured in simulator/ConfigurationSimulator.hpp.
simulator/linux-check.sh reproduces the Linux build, a headless smoke run
(SDL_VIDEODRIVER=dummy) and the asset regeneration in an Ubuntu container, and is
what the Linux checks for this app are based on.
Why LVGL Works on a TouchGFX Kernelļ
The kernel does not know which toolkit draws an appās screens. A GUI process is an ELF loaded into SRAM that talks to the kernel through messages, and the contract is small:
The kernel provides |
Form |
|---|---|
A frame tick |
|
Button input |
|
Lifecycle |
|
Service messages |
Any app-defined message type, forwarded from the service process |
The GUI provides |
|
Frames |
|
The kernel copies the buffer into its own surface and composites it. Nothing in that
contract mentions TouchGFX. SDK::GuiCommandProcessor (called TouchGFXCommandProcessor
before this app existed; the old name remains as an alias) is the message pump that
implements the GUIās side of it, and both ports use it unchanged.
Two constraints are worth knowing before choosing a toolkit. The GUI process runs in
a fixed RAM region reserved when it is loaded (GUI_RAM_LENGTH in the appās CMake), so
a toolkitās working memory is best kept on a static pool of known size inside it. And
the appās C library is the kernelās export table rather than a full libc. RunLVGL formats
every number with integer arithmetic (gui/include/gui/Format.hpp), which makes its
output independent of that tableās printf support; the tutorial GUIs format with
%.1f through the same table, as their TouchGFX originals do.
The LVGL Portļ
The port lives in the SDK so any app can use it:
File |
Role |
|---|---|
|
LVGL configuration for the watch |
|
|
|
The GUI process entry point |
|
The kernel message pump shared with the TouchGFX port |
|
|
An app selects LVGL in its CMake by using those three variables in place of the
TouchGFX ones and pointing GUI_PATH at its GUI source tree; compare
RunLVGL-CMake/CMakeLists.txt with Running-CMake/CMakeLists.txt.
Displayļ
LVGL renders in RGB565 into a 30-row stripe buffer (14 KB) in PARTIAL render mode.
Each flushed stripe is packed into a persistent 57.6 KB ABGR2222 frame, keeping the top
two bits of each channel, which is the same quantisation the TouchGFX portās
LCD8bpp_ABGR2222 applies, so both toolkits produce identical colours from identical
input. When LVGL reports the last stripe of a frame (lv_display_flush_is_last), the
frame is sent with REQUEST_DISPLAY_UPDATE. Because the frame persists, LVGLās partial
redraws compose correctly onto the previous content, and only the invalidated areas are
re-rendered each frame.
Tick and Frame Loopļ
lv_tick_set_cb reads the kernelās millisecond clock, so animations are time-based and
correct even when ticks arrive unevenly. The frame loop in Port::run() blocks on the
message pump until the next EVENT_GUI_TICK, delivers queued service messages, posts
button codes, then calls lv_timer_handler() once. LVGL therefore renders at most one
frame per kernel tick, which is the watchās 10 Hz.
One configuration choice matters here. LVGLās refresh and animation timers only run
when at least their period has elapsed, and the default period is 100 ms. With the
kernel tick also at 100 ms, any tick that lands a millisecond early was skipped and the
frame waited for the next one, halving the frame rate during animations. lv_conf.h
sets LV_DEF_REFR_PERIOD to 1 so that every tick renders whatever is invalid; the tick
still paces the loop, so this adds no work.
Lifecycleļ
The port registers itself as the pumpās IGuiLifeCycleCallback and forwards
start/stop/resume/suspend/frame to the appās Model. On resume it invalidates the
active screen so the kernel receives a full frame, since another GUI may have owned the
display meanwhile. On stop it calls lv_deinit().
Memoryļ
LVGL uses its built-in allocator on a static pool, LV_MEM_SIZE, sized by measurement:
ScreenManager logs the poolās use and peak on every screen switch, and RunLVGLās peak
over a full session was about 25 KB, so the pool is 40 KB. No stock theme is compiled;
the app styles its widgets itself, which is smaller and closer to the design.
The RunLVGL GUIļ
Software/Apps/LVGL-GUI/
lvgl-gui.cmake sources and include dirs (globbed)
assets/gen_assets.py font and image converter (see Assets)
assets/fonts/*.c 15 Poppins faces, 2 bpp
assets/images/*.c 15 icons
gui/include/gui/
Assets.hpp LV_FONT_DECLARE / LV_IMAGE_DECLARE for the above
Format.hpp integer-only number formatting (pace, distance, time)
Strings.hpp text constants
model/ Model, ModelListener, menu navigation state
screens/ Screen base, ScreenManager, one class per screen
theme/Theme.hpp the app's fonts, plus the SDK's drawing helpers
widgets/ HeartRateZone, InfoCarousel, Map, ... and the SDK widgets
with Run's font and icons filled in
gui/src/
GuiApp.cpp una_lvgl_app_init(): model + first screen
... implementations of the above
simulator/ PC simulator project (CMake) and its sensor config
Entry Pointļ
The SDKās LVGL main.cpp binds the kernel, initialises the port, then calls one hook
the app defines:
extern "C" void una_lvgl_app_init(void)
{
Theme::init();
sModel = new (sModelStorage) Model();
ScreenManager::instance().start(*sModel, ScreenId::Main);
}
Objects are constructed here rather than as globals so nothing touches the kernel
before main() has bound it. The same file defines una_lvgl_default_font(), which
lv_conf.h routes LV_FONT_DEFAULT through; returning one of the appās own faces keeps
LVGLās built-in Montserrat fonts out of the link.
Model and the Service Contractļ
Model is the GUIās single point of contact with the service and the kernel. It
implements IGuiLifeCycleCallback (the portās lifecycle events) and
ICustomMessageHandler (the serviceās messages), keeps the last known state (time,
battery, GPS fix, settings, track data, activity summary) and exposes commands
(trackStart, trackPause, saveLap, saveSettings, exitApp, ā¦) that send the
matching messages to the service.
The message types in Software/Libs/Header/Commands.hpp are unchanged from Run:
SettingsUpd, Time, Battery, GpsFix, TrackStateUpd, TrackDataUpd,
LapEnded, Summary, IntervalsPhaseAlert, IntervalsWorkoutCompleted and
AccessoryStatusUpd flow from service to GUI; SettingsSave, TrackStart,
TrackStop, TrackPause, TrackResume, ManualLap and IntervalsNextPhase flow
back. This is the point of the exercise: the service does not know or care which toolkit
renders its state, and the whole GUI could be swapped again without touching it.
Exactly one screen is bound to the model at a time (Model::bind). The model
forwards each incoming message to the bound screen through ModelListener, a set of
virtual callbacks with empty defaults (onGpsFix, onBatteryLevel, onTrackData,
onLapChanged, onIntervalsPhaseAlert, ā¦), so a screen overrides only what it shows.
State updates are always applied to the model first, whether or not a screen is bound,
so a screen created later reads current values.
Themeļ
The drawing helpers live in the SDK, in SDK/GUI/LVGL/Draw.hpp (namespace
SDK::LVGL::Draw): label, hline, vline, box, image, imageTinted, dot,
arc, container, plus init() for the shared styles and applyScreen() for a black,
unscrollable screen. They take TouchGFX coordinates and angles (arcs measured from 12
oāclock, clockwise; arcAngle() converts to LVGLās 3 oāclock origin) and the SDKās
64-colour SDK::GUI::Color values, which the two-bits-per-channel display renders
exactly, so screens can be laid out straight from a TouchGFX Designerās values and the two
toolkits match to the pixel.
The appās Theme namespace adds what is Runās own: the fifteen Poppins faces by weight
and size (Theme::Font, Theme::font()), a label() overload that takes one of them,
and using-declarations that make the SDK helpers available as Theme::arc(...) and so
on. The SDK owns no fonts or images, so every helper and widget that draws text or an
icon takes it as a const lv_font_t* or const lv_image_dsc_t* from the app.
Widgetsļ
The widgets every UNA activity app shares are part of the SDK, under
SDK/GUI/LVGL/ in namespace SDK::LVGL: Buttons (the bezel hints), Title,
ScrollIndicator, SensorStatusRow, Battery, TimerRing, Toggle and WheelMenu.
Each is a small class that creates LVGL objects on a parent and keeps the handles it
needs to update; where one draws text or an icon, the constructor takes the font or
image from the app (Title takes its face, SensorStatusRow its two A8 glyphs,
WheelMenu a Fonts struct and, per item, an optional face for the selected slot).
RunLVGLās Widgets.hpp re-exports them under its Widgets namespace, with two-line
subclasses for Title and SensorStatusRow that fill in Runās font and icons, and its
WheelMenu.hpp does the same for the wheelās fonts and slide time. The rest of
Widgets.hpp is Runās own: HeartRateZone, PauseIndicator, InfoCarousel, Map,
IntervalsTimer and TwoTonePicker. Two are worth reading for technique:
WheelMenureproduces the scroll wheelās 400 ms slide the way TouchGFXāsScrollWheelWithSelectionStyledoes: two strips of three slots, one in the large selected style clipped to the selection window and one in the small style clipped to the area below, moved together by one item pitch withlv_anim. It reports its slide midpoint so the screen can recolour the lens when the incoming item takes the centre, as Run does.HeartRateZonedraws the five-segment zone bar and the active-zone marker withlv_arcobjects and one triangle drawn in anLV_EVENT_DRAW_MAINhandler, with geometry fitted from Runās bitmaps. It replaced about 50 KB of images with about 100 lines of code and no bitmaps, which is the general lesson for LVGL on this platform: shapes are cheap, pixels are not.
Assetsļ
assets/gen_assets.py converts Runās Poppins TTFs and PNG icons into LVGL C arrays, and
the outputs are committed:
Fonts through
lv_font_conv(run vianpx, so Node is needed) at 2 bits per pixel, uncompressed. Faces used only for numbers carry digits and punctuation only, which keeps all fifteen faces to about 80 KB; the one exception is documented in the script (the selected Start item uses a face that must stay full ASCII).Images through LVGLās own
LVGLImage.py(needs thepypngandlz4Python packages). Multi-colour icons areRGB565A8; single-colour icons (ticks, crosses, pause, sensor glyphs) areA8alpha masks tinted at draw time withTheme::imageTinted(), so one bitmap serves every colour variant. Do not use indexed formats to save space: LVGL v9 decodes them to 32-bit ARGB in the pool at draw time.
The generator runs the converters with SDK-relative paths and normalises the output to
LF, so a regeneration on Windows or Linux reproduces the committed files byte for byte.
Declarations in Assets.hpp sit inside extern "C", which MSVC needs to link C-defined
symbols from C++.
Memory and Performanceļ
The GUI process is loaded whole into SRAM, so its size is the number that matters:
RunLVGL |
Run (TouchGFX) |
|
|---|---|---|
|
~408 KB |
~529 KB |
GUI process RAM as loaded |
~452 KB |
~493 KB |
of which code and assets |
~308 KB |
|
of which static data |
~139 KB: 40 KB LVGL pool, 58 KB frame, 14 KB stripe, the rest LVGL and app state |
The kernel needs one contiguous block for the whole process, so watch the headroom in
the kernelās System Heap log lines when other apps are resident. GUI_RAM_LENGTH and
GUI_STACK_SIZE in the appās CMake set the region and the GUI taskās stack; LVGLās
software renderer recurses deeper than TouchGFX and needs the 24 KB stack RunLVGL gives
it.
Rendering a full-screen animation frame takes about 40 ms of the 100 ms period on the
watchās Cortex-M33. Configuring the app with -DRUNLVGL_FRAME_STATS=ON logs one line per
100 kernel ticks with tick spacing, frames sent, render time and hand-off time, which is
how the numbers in this section were measured.
Pitfalls met while writing this app, so you do not have to:
LV_IMAGE_DECLARE()has no trailing semicolon in its expansion whileLV_FONT_DECLARE()has; a missing;produces a cascade of unrelated errors.lv_obj_remove_flag(obj, A | B)needsstatic_cast<lv_obj_flag_t>in C++.lv_point_precise_tis integer unlessLV_USE_FLOATis on; round before assigning.LV_FONT_DEFAULTmust be a font you ship; theuna_lvgl_default_font()hook exists so Montserrat can be left out of the link.Weak symbols do not exist on MSVC; the port uses
/alternatenamefor the same effect in the simulator build.
What Differs from the TouchGFX Run Appļ
Run (TouchGFX) |
RunLVGL |
|
|---|---|---|
Layout |
TouchGFX Designer, generated base classes |
Code, using the same coordinates |
Screens |
View + Presenter per screen, generated |
One |
Text and fonts |
Designer text database, generated typed texts |
Plain strings, fonts converted once |
Animations |
TouchGFX animated widgets and |
|
Simulator |
Visual Studio project generated by Designer, Windows only for the IDE flow |
One CMake project, Windows and Linux |
Service |
Identical |
Identical |
The service, the FIT file format, the settings format and the message contract are the same. A recording made with RunLVGL carries RunLVGLās own APP_ID in its FIT file, and its settings live in RunLVGLās own directory, so the two apps keep their data apart.
Where to Go Nextļ
To start your own LVGL app, copy RunLVGL-CMake/CMakeLists.txt and the GuiApp.cpp,
Screen, ScreenManager, Theme and Model skeletons, keep the port as it is, and
write your screens. The simulator project needs only the appās source lists and its
ConfigurationSimulator.hpp. For the kernel-side details of what a GUI process may do,
see the TouchGFX Port Architecture, most of which
describes the shared message pump and applies to LVGL unchanged, and
Simulator for the mock kernel the simulator runs on.