Skip to content

Page lifecycle

PageManager owns the dashboard’s page tree, the LVGL screens it represents, and the swipe/transition state machine. Memory pressure on a no-PSRAM WROOM makes this the most fragile part of the UI. Sources: src/ui/page_manager.cpp, src/ui/page_manager_anim.cpp, src/ui/page_manager_builder.cpp, src/ui/widget_factory.cpp.

Lazy build, not eager

Only the default page is built eagerly at boot. Other pages stay as Page{built=false, screen=nullptr} entries until first navigation. The LVGL pool would OOM on gauge-heavy configs if every page were materialised at boot — the pool runs ~80 KB and a single dense page can claim 20 KB+ in arc + label objects.

When a swipe / navigateTo lands on an unbuilt page, showPage() schedules the build via lv_async_call(asyncDoLazyBuild, nullptr) instead of doing it inline. The async callback runs at the start of the next lv_timer_handler() iteration, outside any LVGL event callback. This avoids the use-after-free that occurs when lv_obj_del(dep.screen) is called synchronously from within a button click handler: lv_obj_del fires LV_EVENT_DELETE on the button, which frees its tag, and the click handler then continues with a dangling pointer.

If a second lazy-build request lands while the first is still pending, s_pendingLazyBuildIdx is overwritten (latest-wins debounce) and the second lv_async_call is skipped — the queued handler runs once and uses the most recent index. This matches typical UI debounce semantics.

Release on the next navigation

LVGL screen transitions need both screens alive while the animation plays. showPage() defers the release of the departing page until the next call: s_pendingFreeIdx = s_currentIdx after starting the animation, and the head of the next showPage() invocation (s_pendingFreeIdx < s_pageCount && s_pendingFreeIdx != idx) actually frees it via WidgetFactory::clearAll + lv_obj_del. Skipping the release when the user navigated back to the very page that was scheduled for release is intentional — the page is now the current page again.

Lazy-build dummy screen UAF guard (#1295)

When asyncDoLazyBuild runs after a swipe-back, the screen the user is swiping toward may have been released between the swipe and the async callback. The callback creates a blank “placeholder” screen, swaps it in via lv_scr_load, releases the old screen if it’s still alive, then finally builds the target page and runs lv_scr_load_anim with placeholderActive=true. This pattern was the fix for #1295.

Swipe arbitration

onSwipe(dir) filters when the press object was clickable (lv_indev_get_obj_act() + LV_OBJ_FLAG_CLICKABLE). The user has tapped a button — a swipe that incidentally crosses the button’s bounds should NOT navigate. Without this guard, dragging a finger across a button-filled page mid-tap navigated unexpectedly.

gesture_controller.cpp also drives an early swipe via cancelClickIfSwiping: once horizontal travel passes SWIPE_CANCEL_THRESHOLD_PX = 8 px, the indev is reset (lv_indev_reset_long_press + lv_indev_reset) and the swipe is dispatched directly. Waiting for LVGL’s own cumulative gesture_limit left a 12–40 px dead zone where the click was cancelled but no swipe fired — the page sat still and the user thought the swipe was lost (#1262).

Rev-limiter overlay layer

s_revOverlay is a red translucent rectangle on lv_layer_top() that sits above pages but below ErrorBar / DiagDrawer / Settings. It is visibility-toggled by AlertEngine::isRevLimiterFlashOn() from updateWidgets(). Mounting on lv_layer_top means it survives page transitions (the layer is shared across screens), so the alert flash keeps blinking through a swipe.

MAX_PAGES = 5

The cap evolved through several iterations and the current value is load-bearing:

  • 4 (pre-#1351): too tight for cruise_control + 4 free-form pages.
  • 8 (#1357): jumped +25 KB BSS, OOM’d CAN init + USB rxBuf at boot (#1358 revert).
  • 5 (now, #1360): minimal bump that fits the demo seed (4 pages) + 1 cruise. Costs +6.2 KB BSS vs the 4-page baseline — well inside the post-#1351 WiFi-removal headroom (~80 KB). The 25 KB jump from 4→8 was what hit fragmentation; +6.2 KB is comfortably below the cliff.

Heap-allocating the page array (#1359) is the durable fix and would retire this static cap entirely.

Widget tag pool

Every widget that needs per-instance state (gauge cache, button latch, warning blink state, etc.) backs that state with a slot from WidgetTagPool. The pool is sized to CFG_MAX_WIDGETS_PER_PAGE slots — one page is bounded to that many widgets, so the pool can never be exhausted during a single page build. When a widget is destroyed, LV_EVENT_DELETE fires WidgetTagPool::deleteHandler<Tag> which releases the slot.

WidgetTagPool::Slot<Tag> is an RAII guard used at widget-create sites: if the create() function returns early before lv_obj_add_event_cb attaches deleteHandler, the guard’s destructor releases the slot deterministically. After tagSlot.commit() LVGL owns destruction.