How an app works
SDK 1.xWritten for core 1.53+ and gui 1.23+.
This page is the contract between your app and the CrossPad framework: when your code is called, on which task, with how much memory, and what it must clean up. Every example in the cookbook follows these rules. Follow them and your app behaves the same on every visit, in the simulator and on the board.
The code on this page comes from three examples: Hello screen, Pads and LEDs and UI kit.
What an app is
Section titled “What an app is”An app is two plain functions and one registration line. You do not subclass anything.
lv_obj_t* MyApp_create(lv_obj_t* parent, App* app); // build the screen under parent, return its rootvoid MyApp_destroy(lv_obj_t* root); // free what create madeApp is the platform’s app class. It has the same name on both targets, but each declares its own: the firmware’s comes from crosspad-gui (crosspad-gui/app/App.hpp), the simulator’s from pc_stubs/PcApp.hpp. Both derive from AppBase, which holds everything this page uses. The examples include whichever header the build has:
#if __has_include("pc_stubs/PcApp.hpp") #include "pc_stubs/PcApp.hpp" // the simulator's App#else #include "crosspad-gui/app/App.hpp" // the firmware's App#endifThe registration line names the two functions:
REGISTER_APP(HelloScreen, nullptr, "example-hello-screen.png", HelloScreen_create, HelloScreen_destroy, nullptr, nullptr, nullptr, 0)Source: examples/01-hello-screen/src/HelloScreenApp.cpp, lines 152–154 (repository opens soon)
REGISTER_APP takes nine arguments, four of which it ignores:
| # | Argument | What to pass |
|---|---|---|
| 1 | appName | A bare C++ identifier in CamelCase. It becomes the app’s name: the label in the simulator’s launcher, and the name mode_overlay_open_app() takes. Make it unique among all apps, ignoring case (names are compared without case, and the first match wins); two apps with exactly the same name do not even link, because each registration defines a function _register_<appName>_app(). |
| 2 | logicFunc | nullptr. The macro drops it. |
| 3 | iconPath | The icon’s file name in your app’s assets/ folder: a 48 × 48 px PNG with transparency (RGBA). Name it after your app id; every app’s icons share one folder (Limits). |
| 4, 5 | createLVGL, destroyLVGL | Your two functions. |
| 6, 7, 8 | initSimple, refreshFunc, cleanupFunc | nullptr each. The macro drops them. |
| 9 | priority | 0. Higher values sort earlier in the app list; equal values keep registration order. |
| 10 (optional) | recolorIcon | true to tint a one-colour icon (black on transparent) with one flat colour. |
An app that owns the pads uses REGISTER_APP_PL, which adds the name of its pad logic as the tenth argument; the framework then hands the pads to that logic while the app is open:
REGISTER_APP_PL(PadsLeds, nullptr, "example-pads-leds.png", PadsLeds_create, PadsLeds_destroy, nullptr, nullptr, nullptr, 0, "PadsLeds")Source: examples/02-pads-leds/src/PadsLedsApp.cpp, lines 158–160 (repository opens soon)
The tenth argument is a name, not a handler. Register the handler yourself with registerPadLogic, under exactly that string, once, and before create returns: the framework gives the pads to the logic right after create. Pads and LEDs does it at the top of create, on the first visit only:
// Registered once and kept: REGISTER_APP_PL below makes the framework// push it when the app opens and pop it when the app closes.if (!s_logic) { s_logic = std::make_shared<PadsLedsPadLogic>(); crosspad::getPadManager().registerPadLogic("PadsLeds", s_logic);}Source: examples/02-pads-leds/src/PadsLedsApp.cpp, lines 92–97 (repository opens soon)
With a name it cannot find, PadManager prints PadManager: App '<name>' not found, using default pad logic on the console, and your handler never sees a pad.
Keep the registration in a .cpp file under your app’s src/ folder: the build finds apps by scanning those files for the macro, and generates the list of apps from what it finds. On the firmware, the app is an ESP-IDF component whose CMakeLists.txt sets WHOLE_ARCHIVE ON, so the linker keeps the registration function that nothing else calls. The examples’ CMakeLists.txt is the one to copy.
Lifecycle
Section titled “Lifecycle”The framework calls your code in this order:
- Boot. The generated registry calls every app’s registration function. The framework keeps the apps that have a create function, sorts them by priority and makes one
Appobject per app. That object lives until the board powers off; your create has not run yet. - Open. The user opens your app from the MODE map or the simulator’s launcher, or another app calls
mode_overlay_open_app(). The framework:- closes the app that was on screen (its destroy runs now: only one app is on screen at a time);
- remembers which group the encoder was driving;
- calls your create with the app area as
parent, then the show handler if create set one withsetOnShow; - with
REGISTER_APP_PL, gives the pads to the pad logic registered under the name in argument 10: itsonActivateruns.
- Running. Your code runs from LVGL timers and widget events, from pad callbacks, and from event subscriptions made with
subscribe. Each of those runs on its own task: see Threads. - Back. A short click of the power button closes the most local thing first: an open dropdown, then the newest handler registered with
ui_back_push(a dialog, an overlay), then the encoder’s edit mode (a slider being turned), then the transport bar if the encoder is on it, and only then asks your app. YoursetOnBackhandler returnstruewhen it handled the click (it closed a panel of yours, the app stays) andfalseto let the framework close the app. With no handler, the click closes the app. - Close. Your app closes itself with
app->destroyAppAsync(), or the user leaves it (back, MODE, another app). The framework then:- takes the pads back from your pad logic (its
onDeactivateruns); the logic that had the pads before gets them again; - calls your destroy with your root;
- gives the encoder back to the group it was driving before your app opened.
- takes the pads back from your pad logic (its
One visit to an app, top to bottom. Blue boxes are your code; the dashed box is optional. The pad logic steps happen only for an app registered with REGISTER_APP_PL, and the Sampler is the base pad logic that has the pads whenever no app does.
Here is create, from Hello screen:
lv_obj_t* HelloScreen_create(lv_obj_t* parent, App* app){ s_group = nullptr; s_root = nullptr; s_count = nullptr; s_details = nullptr; s_presses = 0;
// The encoder drives whichever group is on it. This app's buttons go in // its own group; the framework gives the encoder back when the app closes. if (lv_indev_t* enc = crosspad_gui::getGuiPlatform().getNavigationEncoder()) { s_group = lv_group_create(); lv_indev_set_group(enc, s_group); }
lv_obj_t* root = lv_obj_create(parent); s_root = root; lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(root, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* title = lv_label_create(root); lv_label_set_text(title, "Hello, CrossPad");
add_button(root, "Press me", on_press, nullptr); s_count = lv_label_create(root); lv_label_set_text(s_count, example_hello::pressedLabel(0).c_str()); add_button(root, "Details", on_details, nullptr); add_button(root, LV_SYMBOL_CLOSE " Close", on_close, app);
s_details = lv_obj_create(parent); // on top of root, hidden until asked lv_obj_set_size(s_details, LV_PCT(100), LV_PCT(100)); lv_obj_set_style_bg_opa(s_details, LV_OPA_COVER, 0); lv_obj_set_style_pad_all(s_details, 16, 0); lv_obj_t* note = lv_label_create(s_details); lv_label_set_text(note, "A panel inside the app.\n\n" "Click the power button:\nthis panel closes, the app stays."); lv_obj_set_hidden(s_details, true);
app->setOnBack(on_back); return root;}Source: examples/01-hello-screen/src/HelloScreenApp.cpp, lines 90–132 (repository opens soon)
Three things in it matter beyond this app. It resets every file-scope variable first, because they keep their values from the last visit (the App object and your statics outlive the screen). It puts its buttons in a group of its own and gives that group to the encoder from getNavigationEncoder(), which is null on a platform without one; it does not need to restore the old group, the framework does. And it sets its back handler with setOnBack inside create, where app is at hand.
The back handler closes the app’s own panel and keeps the app open; when there is no panel to close, it lets the framework close the app:
// The power button's click. true = handled here (the panel closes);// false = let the framework close the app.bool on_back(lv_obj_t*){ if (s_details && !lv_obj_has_flag(s_details, LV_OBJ_FLAG_HIDDEN)) { show_details(false); return true; } return false;}Source: examples/01-hello-screen/src/HelloScreenApp.cpp, lines 62–71 (repository opens soon)
A button that closes the app calls destroyAppAsync(), never destroyApp(). The button’s click callback is still running, and destroyApp() would delete that button under it; destroyAppAsync() waits until the event is over:
void on_close(lv_event_t* e){ // Never destroyApp() from inside an event: it tears the app down while LVGL // is still delivering the click. destroyAppAsync() waits for the event to end. auto* app = static_cast<App*>(lv_event_get_user_data(e)); app->destroyAppAsync();}Source: examples/01-hello-screen/src/HelloScreenApp.cpp, lines 52–58 (repository opens soon)
Destroy frees everything create made: timers, event subscriptions (unsubscribe), the encoder group (with lv_group_delete_deferred from crosspad-gui, because the encoder may be in the middle of reading it), and last the root, with lv_obj_delete_async. Hello screen has no timers or subscriptions, only its group and two panels:
void HelloScreen_destroy(lv_obj_t* root){ // root is null when the app's screen was already wiped: free what is ours, delete nothing. if (s_group) { lv_group_delete_deferred(s_group); // the encoder may still be mid-read s_group = nullptr; } if (root && s_details) lv_obj_delete_async(s_details); s_details = nullptr; s_root = nullptr; s_count = nullptr; if (root) lv_obj_delete_async(root);}Source: examples/01-hello-screen/src/HelloScreenApp.cpp, lines 136–148 (repository opens soon)
Write destroy so that it also works with a null root: free your resources and delete nothing, as the examples do.
Hooks to leave alone. Put your cleanup in destroy. These hooks are not part of the app lifecycle:
- the
IApphooksonAppInit,onAppDeinit,onAppUpdateandonAppActivated; pause(),resume()and thesetOnHidehandler.
The AppChanged event (postAppChanged) reports a kit change from the Sampler; it is not an app open or close notification.
Threads
Section titled “Threads”Your code runs on several FreeRTOS tasks on the firmware. LVGL, which draws every screen, is not thread-safe: only the LVGL task may touch it.
| Task | What of yours runs there |
|---|---|
LVGL (esp_lvgl_port), 16 kB stack | create, destroy, the back handler, lv_timer callbacks, widget event callbacks, and everything passed to postToGui |
UART drv (pads from the STM32) | your pad logic’s callbacks for a finger on a pad |
seq_poll (loop playback) | your pad logic’s callbacks for presses a running loop plays (pressSource() returns SequencerCallback) |
crosspad_events, queue of 64 | your event bus subscribers |
audio_rt | an IAudioNode’s mix() and process() |
Pad callbacks can also come from MIDI input, on its own task. Two pad callbacks can run at the same time.
The same tasks as a map, with the ways work reaches the screen: a pad callback writes an atomic that an lv_timer reads, and an event subscriber calls postToGui (or uses an atomic too). The rules below say why.
The rules:
-
Touch LVGL only on the LVGL task. A pad callback that wants the screen to change writes an atomic that an
lv_timerreads. An event subscriber or another task either does the same or hands a function to the LVGL task withcrosspad_gui::getGuiPlatform().postToGui(cb, user_data). The pad logic in Pads and LEDs stores each pad’s level in an atomic; a timer on the LVGL task draws them:02-pads-leds/src/PadsLedsApp.cpp // Runs on the LVGL task every 33 ms: reads what the pad task recorded, draws it.void tick(lv_timer_t*){for (uint8_t p = 0; p < 16; ++p) {const uint8_t lvl = s_logic->level(p);const auto c = example_pads::padColor(p, lvl ? lvl : example_pads::kRestLevel);lv_obj_set_style_bg_color(s_cells[p], lv_color_make(c.r, c.g, c.b), 0);}const int last = s_logic->lastPad();if (last >= 0) {lv_label_set_text_fmt(s_status, "Pad %d level %u", last + 1,static_cast<unsigned>(s_logic->level(static_cast<uint8_t>(last))));}}Source:
examples/02-pads-leds/src/PadsLedsApp.cpp, lines 35–48 (repository opens soon) -
Never call
lv_async_calloff the LVGL task. It is meant for the LVGL task only.postToGuiis the version for every other task: on the firmware it queues the call and hands it to LVGL under the display lock.destroyAppAsync()useslv_async_calltoo, so call it from the LVGL task; from another task, post a function that calls it. -
Guard work you post. A function queued with
postToGuibefore your app closed can run after your destroy, when its widgets are gone. Keep an atomic “alive” flag: set it in create, clear it first thing in destroy, and return early from the posted function when it is clear. UI kit’sshow_pad(below) does exactly this withs_alive. -
Keep pad logic state in atomics. Pad callbacks run concurrently with each other and with the LVGL task. Keep what your callbacks use alive for as long as the pad logic is registered; do not free it in
onDeactivate. -
No flash writes from a pad callback. The pad tasks run on stacks in PSRAM, and a flash write (saving settings, for example) turns off the cache that PSRAM sits behind. Hand the work to the LVGL task instead.
-
Copy event data in the callback. An event’s data is valid only while your subscriber runs (IEventBus). Copy what you need into atomics or post it.
-
Register your pad logic, do not switch it. With
REGISTER_APP_PL, register the handler once under the name in argument 10, before create returns (What an app is); the framework pushes and pops it. Never callsetActivePadLogicorpushPadLogic(PadManager) yourself, and never from insideonActivateoronDeactivate. -
The audio task is real-time.
mix()andprocess()must not allocate, lock, log or touch files. Allocate inonPrepare(), and talk to other tasks through atomics (IAudioNode). -
Some GUI calls are LVGL-task only: every
transportbar_*function (transport_bar), and everymode_overlay_*function (mode_overlay) exceptmode_overlay_select_pad()andmode_overlay_is_open().
Following the pads. To follow the pads the Sampler plays (by a finger or by a running loop), subscribe to SamplePlayback (EventTypes) and keep the events with source App, as the UI kit example does, from the event task, through postToGui:
// On the LVGL task, when the queue reaches it. s_alive is false once the app// is closing, so a hit that was queued before the close does nothing.void show_pad(void* p){ if (!s_alive.load()) return; ui_toast_show(example_ui::padToast(example_ui::unpackPad(p)).c_str(), 800, UI_TOAST_INFO); ++s_pads; show_count();}
// On the event task: no LVGL here. Hand the pad over to the LVGL task.// These are the pads the Sampler plays (from a finger or a running sequence).void on_pad(const void* data, crosspad::EventType, void*){ const auto* d = static_cast<const crosspad::SamplePlaybackData*>(data); if (!d->isOn || d->note >= 16 || d->source != crosspad::EventSource::App) return; crosspad_gui::getGuiPlatform().postToGui(show_pad, example_ui::packPad(d->note));}Source: examples/10-ui-kit/src/UiKitApp.cpp, lines 57–74 (repository opens soon)
When your app’s own pad logic has the pads, PadManager skips what it does by default for a press and a release: no note-on or note-off light, no MIDI note, no NoteOn, NoteOff, PadPressed or PadReleased event. Your logic does what it wants done. One path stays: a held pad’s pressure still reaches the PadLedController, which repaints the pad from it when the LED feedback mode is aftertouch and the pad is lit as pressed; your onPadPressure gets the same value.
Memory
Section titled “Memory”The ESP32-S3 has two kinds of RAM: a little internal RAM and 8 MB of PSRAM. Internal RAM is the one to spare: keep big buffers in PSRAM. To measure it in code, heap_caps_get_free_size(MALLOC_CAP_INTERNAL) (ESP-IDF only) returns the free internal RAM in bytes.
-
LVGL objects live in PSRAM. The firmware routes every LVGL allocation to PSRAM, so widgets and timers cost no internal RAM.
-
newandmallocof more than 64 bytes take PSRAM first. Smaller blocks take internal RAM first, so many small allocations still add up there. -
Big statics go in PSRAM with
EXT_RAM_BSS_ATTR. A file-scope array or struct is placed in internal RAM unless you mark it. Mark anything bigger than a few dozen bytes. The attribute exists only on ESP-IDF; define a macro so the simulator builds too, and give the variable no initializer (the section is zero-filled at boot):#ifdef ESP_PLATFORM#include "esp_attr.h"#define APP_PSRAM_BSS EXT_RAM_BSS_ATTR#else#define APP_PSRAM_BSS#endifnamespace {APP_PSRAM_BSS uint8_t s_history[16][128]; // 2 kB, zero at boot} -
The SD card cannot move data straight into PSRAM. The card controller works by DMA, which cannot reach PSRAM, so a read into a PSRAM buffer (any
mallocblock over 64 bytes) goes one 512-byte block at a time through a small internal buffer. For the fastest reads, read into a small internal buffer, as the firmware’s own sample streamer does. -
Decoded images take PSRAM. Keep images small and few.
A leak on the firmware is internal RAM that does not come back after your app closes. Checking your app shows how to measure it.
What outlives the screen
Section titled “What outlives the screen”Closing an app deletes its screen and nothing else. These stay, some on purpose:
- File-scope variables. They keep their values until power-off. Reset them at the top of create (Hello screen does) and set pointers to widgets back to null in destroy.
- Your registered pad logic. Register it once, keep it registered, and let
REGISTER_APP_PLpush and pop it by name. Pads and LEDs keeps its handler in a staticshared_ptrfor this reason. - Event subscriptions you did not remove. A subscriber keeps running after the screen is gone, on the event task, and any widget pointer it uses now points at freed memory. Unsubscribe in destroy, unless the app should keep listening (the Sequencer keeps its tempo subscription on purpose).
- A transport claim. When your app claims the transport with
transportClaim, PLAY and STOP drive it until another target claims it, even after your screen closes. That is on purpose: the Sequencer keeps playing when you leave it. An app whose target means nothing without its screen stops and releases it in destroy, as Tempo and transport does. - The base pad logic. While no app owns the pads, the Sampler plays them. Nothing of yours is needed for that.
Limits
Section titled “Limits”-
24 apps. The registry holds 24 apps, built-in ones included. A firmware with every bundled app registers 15 (Settings, Firmware, KitSelector and a Power OFF entry among them), which leaves 9 for yours. An app past the limit is not registered, and the console says
AppRegistry: no room for '<name>'at boot.To count yourself: the firmware build prints
Generated app_registry_init.cpp with N apps: …while it configures (the Power OFF entry is added at run time, so the board has one more). -
One folder for every icon. The build copies every app’s
assets/*.pnginto one flat folder. Two files with the same name collide, one icon replaces the other, and CMake printsAsset collision. Name your icon after your app id, as the examples do (example-hello-screen.png). -
A new app needs a clean configure. The build looks for apps when it configures, so after adding one run
idf.py -B build_v2 fullcleanon the firmware (build_v1for a v1 board), or reruncmake -B buildfor the simulator.
Checking your app
Section titled “Checking your app”Three layers of checks, from fastest to closest to the real thing:
- Host tests. Keep the logic you can test (the numbers, the state) in plain C++ headers without LVGL, and test them with Catch2 on your computer. Every example has a
tests/folder that does this. - The simulator. Open your app, use it, close it, and open it again. Most second-visit problems show here. What the simulator does differently: Simulator vs CrossPad.
- The board. Open your app, use it, close it, and open it again, several times. Then go deep into it and press the power button to come all the way back home. On the firmware, check with
heap_caps_get_free_size(MALLOC_CAP_INTERNAL)that internal RAM comes back after each close (Memory).
When something goes wrong
Section titled “When something goes wrong”| Symptom | Likely cause | Fix |
|---|---|---|
| Works on the first visit, not on the second | A file-scope pointer still points at a widget from the last visit, or a timer or subscription from the last visit is still running | Reset your statics at the top of create; in destroy, delete every timer, unsubscribe, and set widget pointers back to null |
LoadProhibited in an lv_timer callback after the app closed | A timer of yours was not deleted and reaches deleted widgets | lv_timer_delete every timer in destroy, before the root goes |
LoadProhibited in lv_obj_get_state or lv_group_get_focused around a close | The encoder group was deleted while the encoder was still using it, or a saved group was put back after it was deleted | Delete groups with lv_group_delete_deferred; leave restoring the encoder to the framework, and if you restore one yourself, use lv_indev_restore_group, which skips a deleted group |
LoadProhibited somewhere in LVGL, far from your code, now and then | LVGL touched from a pad callback or an event subscriber, or lv_async_call from another task | Atomics read by an lv_timer; from an event subscriber or another task, postToGui (Threads) |
| The encoder stops responding after closing with a dialog open | A ui_modal_confirm dialog hands the encoder back to the group you gave it, which destroy has deleted | Dismiss open dialogs with ui_modal_confirm_dismiss in destroy, before deleting the group |
| The app is not in the simulator’s launcher or the build’s app list | The registry is full (console: AppRegistry: no room for '<name>'); the build was not reconfigured after the app was added; the registration is not in a .cpp under src/, or the component lacks WHOLE_ARCHIVE ON | Count the apps (Limits); fullclean and build again; copy the example’s layout and CMakeLists.txt |
The link fails with a multiple definition of _register_<Name>_app() | Two apps registered the same name | Rename one: the first argument of REGISTER_APP |
| A blank icon, or another app’s icon | Another app’s file has the same name (Asset collision in the configure log), or the assets on the board are older than the build (an OTA update does not rewrite them) | Name the icon after your app id; send the assets as in Start from the template, step 5 |
| The app does not react to the pads | The pad logic was not registered, or under another string than argument 10 of REGISTER_APP_PL (console: PadManager: App '<name>' not found, using default pad logic), or the app uses REGISTER_APP | Register the handler once, under the same string, before create returns (What an app is) |
| After the app closes, the pads keep its colours, or a note keeps sounding | onDeactivate did not clear the colours the logic set, or did not stop a note it started | In onDeactivate, call clearPadColor for every pad you coloured and stop your notes, as Pads and LEDs and Play a sample do |