Skip to content

UI Components Module

The ui_components module (main/display/components/) provides the reusable, self-contained LVGL widget building blocks that power every screen in the Pibot CNC Pendant firmware. Rather than duplicating navigation and input logic screen by screen, this module centralises six distinct component types behind a consistent lifecycle and encoder-handling contract.

Primary consumers: common_screens, cnc_shared screens (jog, status, macros, settings), and firmware-specific screens (FluidNC, grbl, grblHAL).
Primary dependencies: ui_core (UIManager, ThemeStyles, theme tokens), Core Platform (FreeRTOS, LVGL), and Input_system (hardware encoder/button events).

⚠️ LVGL threading constraint: All component methods must be called from Core 1 (the LVGL task). The only exception is RotaryBaseComponent::handleEncoderEvent(), which uses a FreeRTOS mutex to safely accumulate encoder steps from the ISR before dispatching them on the LVGL task.


Screen Layer

Input System (BSP)

ui_core

ui_components (main/display/components/)

inherits

inherits

inherits

RotaryBaseComponent

(abstract base)

CircularMenuComponent

ListMenuComponent

PanelComponent

VirtualButtonsComponent

customKeyboard

(namespace)

UIManager

(ui_manager global)

ThemeStyles

applyTextareaCursorStyle()

Hardware Encoder

LV_EVENT_KEY / CONTROL_FAMILY_ENCODER

Hardware Buttons

LV_EVENT_KEY

Hardware Switch

LV_EVENT_SWITCH_*

Potentiometer

ADC value

Common Screens

CNC-specific Screens


«abstract»

RotaryBaseComponent

-lv_obj_t* parent_

-ESP3DScreenType screen_id_

-SemaphoreHandle_t mutex_

-int32_t accumulated_steps_

-int64_t last_event_time_

-int64_t debounce_threshold_

+handleEncoderEvent(lv_event_t*) : void

+setDebounceThreshold(int64_t) : void

+setAccumulationLimits(int32_t, int32_t) : void

#processEncoderStep(int32_t, int32_t, int32_t) : void

#onEncoderProcessingStart(int32_t, int32_t) : void

#onEncoderProcessingEnd(int32_t, int32_t) : void

CircularMenuComponent

-CircularMenuData data_

-int32_t orientation_

-uint8_t encoder_enabled_index_

-MenuMemory[] menu_memories_

+CircularMenuComponent(lv_obj_t*, ESP3DScreenType, circular_menu_conf_t&, int32_t)

+updateOrientation(int32_t) : void

+updateThemeColor() : void

+simulate_click(int32_t) : void

+releaseSection(int32_t) : void

+get_current_section() : int32_t

+enableEncoderFor(uint8_t) : void

+update_section_icon(int32_t, lv_image_dsc_t*) : void

+updateSectionTranslatableText(int32_t, ESP3DLabel) : void

+updateTranslations() : void

+countEnabledSections() : uint32_t

+getVisualIndex(int32_t) : int32_t

+showMenu() : void

+prepareForDestruction() : void

#processEncoderStep(int32_t, int32_t, int32_t) : void

ListMenuComponent

-lv_obj_t* list_container_

-lv_obj_t* main_container_

-const void* item_list_

-uint32_t item_count_

-int32_t selected_index_

-list_node_t* head_

-list_node_t* tail_

-uint32_t visible_lines_

+move_to_index(int32_t) : void

+move_up() : void

+move_down() : void

+get_selected_index() : int32_t

+updateItemList(const void*, uint32_t, uint32_t) : bool

+refresh_visible_items() : void

+refresh_single_item(int32_t) : void

+forceFooterDisplay(bool) : void

+setFooterActionZoneEnabled(bool, uint32_t) : void

+setEncoderInterceptCallback(cb, void*) : void

+prepareForDestruction() : void

#processEncoderStep(int32_t, int32_t, int32_t) : void

PanelComponent

-PanelItem[] items_

-int32_t focused_item_id_

-uint8_t encoder_mode_

-panel_callback_t global_callback_

+addItem(lv_obj_t*, vector<string>, bool) : int32_t

+removeItem(int32_t) : bool

+disableItem(int32_t, bool) : bool

+setFocusedItem(int32_t) : bool

+getFocusedItem() : int32_t

+getCurrentValue(int32_t) : string

+updateItemText(int32_t, string) : bool

+enableEncoderFor(uint8_t) : void

+activateItem(int32_t) : bool

+prepareForDestruction() : void

#processEncoderStep(int32_t, int32_t, int32_t) : void

VirtualButtonsComponent

-lv_obj_t* buttons_[BUTTONS_NB]

-lv_obj_t* icons_[BUTTONS_NB]

-virtual_button_conf_t configs_[BUTTONS_NB]

-int32_t orientation_

+update_button(int32_t, lv_image_dsc_t*, bool) : void

+show_button(int32_t, bool) : void

+simulate_click(int32_t) : void

+updateOrientation(int32_t) : void

+updateThemeColor() : void

+attachSwitchEmulation(lv_obj_t*, uint32_t, bool) : void

+emitEncoderStep(int32_t) : void

+resetEncoderSteps() : bool

+getSwitchPosition() : uint32_t

+getPotValue() : uint32_t

+prepareForDestruction() : void

«namespace»

customKeyboard

+create(KeyboardConfig&) : lv_obj_t

+set_type(lv_obj_t*, KeyboardType) : void

+set_textarea(lv_obj_t*, lv_obj_t*) : void

+get_type(lv_obj_t*) : KeyboardType

+destroy(lv_obj_t*) : void

+finalize_numeric_input(lv_obj_t*, bool) : void


File: main/display/components/rotary_base_component.h

The abstract foundation all encoder-driven components build on. It handles the ISR-to-LVGL-task boundary safely using a FreeRTOS mutex, accumulates rapid encoder ticks with configurable debouncing, and dispatches them in a controlled batch through the pure virtual processEncoderStep().

Key responsibilities:

  • Thread-safe step accumulation from encoder ISR events (handleEncoderEvent)
  • Debounce filtering (configurable threshold, default tuned for pendant hardware)
  • Batch dispatch: calls onEncoderProcessingStart → processEncoderStep per step → onEncoderProcessingEnd
  • Stores screen_id_ so derived classes can persist per-screen state

Design contract for derived classes:

MethodRequiredPurpose
processEncoderStep(direction, step_number, total_steps)YesHandle one encoder tick
onEncoderProcessingStart(direction, total_steps)NoPre-batch UI prep (e.g. suppress animations)
onEncoderProcessingEnd(direction, total_steps)NoPost-batch finalisation (e.g. persist state)

Encoder event dispatch flow:

LVGL (Core 1)Derived ComponentRotaryBaseComponentEncoder ISR (Core 0)LVGL (Core 1)Derived ComponentRotaryBaseComponentEncoder ISR (Core 0)loop[For each accumulated step]handleEncoderEvent(e)Acquire mutexAccumulate steps (with debounce)Release mutexprocessAccumulatedSteps()onEncoderProcessingStart(direction, total)processEncoderStep(direction, step_n, total)Update UI objectsonEncoderProcessingEnd(direction, total)

File: main/display/components/circular_menu_component.h

A radial section-based menu rendered on a circular canvas. Sections are evenly distributed around the ring; a highlighted arc and centre label indicate the current selection. Supports both hardware encoder navigation and — when ESP3D_HARDWARE_ENCODER_FEATURE is not set — an angular drag-handle for touch-only boards.

Configuration macros:

MacroDescription
TRANSLATABLE_SECTION(icon, label, press_cb, release_cb, lockable)Section using ESP3DLabel translation
RAW_TEXT_SECTION(icon, text, press_cb, release_cb, lockable)Section using a raw string label
TRANSLATABLE_CUSTOM_SECTION(...)Translatable section with custom colour and colour mode
RAW_TEXT_CUSTOM_SECTION(...)Raw-text section with custom colour and colour mode

Key types:

// Single section descriptor
typedef struct {
UIItemType type; // Image | Hidden
const void *img_path; // lv_image_dsc_t pointer
TextType text_type; // Translatable | Raw
ESP3DLabel translatable_label;
std::string raw_text;
void (*on_press)(int32_t section_id);
void (*on_release)(int32_t section_id, uint32_t duration_ms);
bool isLockable;
uint32_t color; // RGB
MenuColorType color_mode; // Theme | CustomDarker
bool enabled; // v2.1: runtime enable/disable
} circular_menu_section_conf_t;

Section enable/disable (v2.1): The enabled field hides a section from the visual layout without removing it from the configuration array. Use countEnabledSections() to get the visual count and getVisualIndex(section_id) to map a config index to its visual layout position.

Section memory: The last active section for each ESP3DScreenType is saved in the static menu_memories_ vector, so returning to a screen restores the previous selection automatically.

Colour modes:

MenuColorTypeNormal statePressed state
ThemeESP3D_MENU_ICON_COLOR (theme token)Lighter theme shade
CustomDarkercolor fieldDarker shade of color field

Section lifecycle:

showMenu()

on_press callback

on_release callback

Encoder step / drag

update_section_type(Hidden)\nor enabled=false

update_section_type(Image)\nor enabled=true

Idle

Pressed

Released

NextSection

Disabled

Encoder index modes:

encoder_enabled_index_Behaviour
ESP3D_MENU_SELECTION (0)Encoder rotates the selection between sections
Other indexEncoder sends value-edit events via on_encoder_event callback

Center disk = OK (a14): the center disk (data_.center_disk) is clickable. A tap presses and releases the currently selected section, exactly like the OK button (onCenterDiskEvent() → simulate_click() / releaseSection()). The release always goes to the section that was pressed, even if the selection changed meanwhile. A tap is ignored, and the disk’s pressed state removed, when no section is selected or the action is not allowed. Only the disk is clickable, not the center ring, so the touch-only drag handle’s band stays free.

Pressed feedback (a14): while a section is pressed (touch on its icon, on the center disk, or the OK button), setPressedFeedback() darkens the selection sector (inner_arc) and the center disk by 30 % towards black (CIRCULAR_MENU_PRESSED_DARKEN). The darker color is computed at press time from the current fill and set as an LV_STATE_PRESSED local style, so it works with every theme and with per-section colors. LVGL color filters cannot be used: LV_USE_COLOR_FILTER is 0 in every board’s lv_conf.h (see ui_style_guide.md).

Selection geometry (a14): the selected-section fill leaves the same gap on both sides (outer border, center ring), equal to the ring / center disk gap, so the three gaps are identical by construction (this replaces the per-resolution ESP3D_CIRCULAR_MENU_ARC_WIDTH_TRIM tuning). A second glow arc (data_.inner_glow) is drawn over the center ring and mirrors the outer glow (angles, opacity, color) through syncInnerGlow(), so the selected section is framed on both edges.

Touch-only drag handle (no hardware encoder): When ESP3D_HARDWARE_ENCODER_FEATURE is not defined, a drag handle is rendered near the centre of the ring. In selection mode the handle tracks the absolute finger angle and directly selects whichever section’s arc slice contains that angle (getSectionAtAngle()). In value-edit submode it falls back to relative delta stepping via processEncoderStep().


File: main/display/components/list_menu_component.h

A vertically scrollable list backed by a minimal linked-list virtual renderer. Only the visible rows are materialised as LVGL objects (list_node_t); scrolling reuses nodes via add_node / remove_head / remove_tail to stay memory-efficient on the constrained ESP32 heap.

Virtual rendering window:

head_

(list_node_t*)

Node 0

container

item_idx = top_index_

Node 1

container

item_idx

… visible_lines_ nodes …

tail_

(list_node_t*)

Layout zones:

┌──────────────────────────────────┐
│ ▲ Title Text │ ← title_container_ (optional UP button)
├──────────────────────────────────┤
│ Item 0 (selected highlight) │
│ Item 1 │ ← list_container_ (visible_lines_ nodes)
│ Item N │
├──────────────────────────────────┤
│ [action zone] ▼ │ ← footer_container_ (optional)
└──────────────────────────────────┘

Display and click callbacks:

// Renders one item into a list node container
void (*display_cb)(lv_obj_t *node_container,
const void *item, void *user_data, void *extra);
// Called when an item is clicked / confirmed
void (*click_cb)(const void *item, void *user_data, void *extra);

Encoder intercept: An optional encoder_intercept_cb_t can capture encoder steps before the default move-up/move-down logic runs, enabling reorder mode (used in the macros screen).

Footer modes:

APIEffect
forceFooterDisplay(true)Show footer even when the list fits on one page
update_footer_text(text)Set an action symbol / label in the footer
setFooterActionZoneEnabled(true, n)Split footer: left tappable action zone + right DOWN arrow; wires switch emulation when n ≥ 2

File: main/display/components/panel_component.h

A panel of arbitrary LVGL objects (PanelItem) that the encoder can navigate between and edit. Unlike the list, items are laid out manually by the parent screen; the component only manages focus, value cycling, and the two encoder modes.

Encoder modes:

ConstantValueEncoder behaviour
PANEL_NAVIGATION_MODE0Move focus between items
PANEL_EDITING_MODE1Cycle the focused item’s value list

Unified callback:

typedef void (*panel_callback_t)(
int32_t item_id,
PanelCallbackType type, // ON_CHANGE | ON_FOCUS | ON_ACTIVE | ON_STANDBY
const std::string &value, // current raw value (populated for ON_CHANGE only)
int32_t direction
);

Item state machine:

addItem()

navigateToNextItem()\nor setFocusedItem()

enableEncoderFor(EDITING_MODE)\nor activateItem()

processEncoderStep() cycles value

enableEncoderFor(NAVIGATION_MODE)

disableItem(true)

disableItem(false)

Unfocused

Focused

ON_FOCUS callback fired

Active

ON_ACTIVE callback fired

ON_CHANGE callback per step

ON_STANDBY callback fired

Disabled

a14 changes:

  • setFocusedItem(item_id, silent = false): silent = true skips the focus-change beep. Screens use it when they restore the focus at creation, after addItem() auto-focused the first item (jog screen, probe screens).
  • The last rendered focus and mode (last_rendered_focus_id_, last_rendered_mode_) are per-instance members. They used to be function-level static values shared by every panel; since item ids restart at 0 in each panel, the first focus render of a new panel could be skipped.
  • Re-enabling an item re-applies its current style (default, focused or active). Disabling had forced the label to the disabled color, and nothing restored it until the focus changed.
  • If the focus is lost (for example every item was disabled at once), navigation falls back to the first enabled item, so the encoder recovers instead of going dead.

File: main/display/components/virtual_buttons_component.h

Renders BUTTONS_NB on-screen buttons (defined per-board in board_config.h) that mirror their hardware counterparts. Also acts as the gateway for touch-only emulation of hardware features absent on lower-end boards.

Configuration macros:

MacroPositionHW button ID
BUTTON_1(icon, press_cb, release_cb, lockable)Left (ESP3D_BOTTOM_BUTTON_POSITION_LEFT)0
BUTTON_2(icon, press_cb, release_cb, lockable)Centre (ESP3D_BOTTOM_BUTTON_POSITION_CENTER)1
BUTTON_3(icon, press_cb, release_cb, lockable)Right (ESP3D_BOTTOM_BUTTON_POSITION_RIGHT)2
BUTTON_1_OK(press_cb, release_cb)Left0 — pre-wired OK icon
BUTTON_x_EMPTY()Any-1 (no HW mapping)

Colour modes:

ButtonColorTypeDescription
ThemeUses ESP3D_MENU_ICON_COLOR theme token
CustomDarkerCustom RGB; pressed = darker shade
CustomFilledCustom RGB with filled background

Touch-only emulation static helpers:

MethodPurpose
attachSwitchEmulation(target, cycle_count, affordance)Makes any LVGL object tap-to-cycle the virtual switch. No-op when ESP3D_HARDWARE_SWITCH_FEATURE is set.
emitEncoderStep(steps)Injects a synthetic encoder step event — used by value-edit screens on touch-only hardware
resetEncoderSteps()Resets the accumulated step counter
getSwitchPosition()Returns current switch position (HW or emulated)
getPotValue()Returns current potentiometer ADC value (HW or emulated)

Hardware button event flow:

Parent ScreenVirtualButtonsComponentHardware Button (BSP)Parent ScreenVirtualButtonsComponentHardware Button (BSP)On release event ...LV_EVENT_KEY (hardware_button_safe_handler)Check is_prepared_for_destruction_Check isActionAllowed(btn_idx)applyPressedState(btn_idx)configs_[n].on_press(btn_idx, user_data)applyNormalState(btn_idx)configs_[n].on_release(btn_idx, duration_ms, user_data)

Files: main/display/components/custom_keyboard_component.h/.cpp

A stateless factory namespace that creates LVGL lv_buttonmatrix keyboards linked to a target lv_textarea. Keyboard data (type, callbacks, textarea reference) is stored in the buttonmatrix’s user_data as the internal KeyboardData struct.

KeyboardConfig — creation parameters:

struct KeyboardConfig {
lv_obj_t* parent; // Parent container for keyboard
lv_obj_t* textarea; // Target textarea to write to
KeyboardType initial_type; // Initial keyboard layout
on_ok_cb_t on_ok; // Reserved (OK handled by physical button)
on_cancel_cb_t on_cancel; // Reserved (Cancel handled by physical button)
void* user_data;
bool allow_leading_zeros; // true = PIN mode (preserve "0042" as-is)
};

Keyboard types:

KeyboardTypeRowsDescription
NumericIntPos40–9, backspace, cursor arrows
NumericFloatNeg40–9, ., -, backspace, cursor arrows
NumericListFloat40–9, ., ; list separator, backspace, cursor arrows
NumericListInt40–9, ; list separator, backspace, cursor arrows

mm/in toggle key (a14): enable_unit_toggle(keyboard, inch, cb, user_data) switches a list keyboard (NumericListFloat or NumericListInt only) to a layout with a mm / in key next to backspace. The key label is the unit to switch to (the edited unit is in the title). Pressing it swaps the label, then calls cb(inch, user_data), which swaps the textarea content; the key never inserts text. The key is a system button. After lv_buttonmatrix_set_ctrl_map() the system buttons are marked again, because the call resets the per-button flags. Used by input_screen for the jog step / feedrate lists per unit (jog_unit_lists.md). | AlphanumericABC | 5 | A–Z uppercase + [abc] + [1#] toggles | | Alphanumericabc | 5 | a–z lowercase + [ABC] + [1#] toggles | | AlphanumericNum | 5 | 0–9 + symbols + return-to-letter toggle (remembers last letter mode) |

Keyboard type state machine:

initial_type=AlphanumericABC

initial_type=Alphanumericabc

initial_type=AlphanumericNum

initial_type=NumericIntPos

initial_type=NumericFloatNeg

initial_type=NumericListFloat

initial_type=NumericListInt

"[abc]" pressed

"[ABC]" pressed

"[1#]" pressed\nlast_letter_mode = ABC

"[1#]" pressed\nlast_letter_mode = abc

"[ABC]" pressed

"[abc]" pressed

ABC

abc

Num

IntPos

FloatNeg

ListFloat

ListInt

Button visual classes:

ClassBackgroundText colourWhen applied
Normal characterESP3D_SCREEN_BACKGROUND_COLOR (dark)ESP3D_SCREEN_BACKGROUND_TEXT_COLOR (light)Default state
System buttonESP3D_SCREEN_BACKGROUND_TEXT_COLOR (light)ESP3D_SCREEN_BACKGROUND_COLOR (dark)Backspace, arrows, ABC, abc, 1#, space, ;, mm, in
Pressed (any)ESP3D_ACCENT_SELECT_COLORLightLV_STATE_PRESSED

Numeric input validation (validate_numeric_input) — rules applied in order after each keystroke:

  1. Empty input → "0" (unless PIN mode / allow_leading_zeros)
  2. Remove duplicate decimal points (keep first)
  3. Leading . → prefix with "0" (e.g. "." → "0.")
  4. Leading "-." → change to "-0."
  5. Strip leading zeros unless preceding . or in PIN mode (e.g. "01" → "1", "0.5" stays)
  6. "-" alone → "-0"

finalize_numeric_input(textarea, allow_leading_zeros) — normalises edge cases on OK press:

InputOutput
"", ".", "-.""0"
"-0", "-0.", "0.""0"
"-0.000…" (all zeros, negative)"0"
"123." (trailing decimal point)"123"
Any value in PIN mode (allow_leading_zeros=true)No change

LVGL Task - Core 1

LV_EVENT_KEY

(CONTROL_FAMILY_ENCODER)

mutex acquire

accumulate steps

mutex release

CircularMenu

selection mode

Panel

editing mode

List

navigation

next flush cycle

BSP encoder ISR

(Core 0)

RotaryBaseComponent

::handleEncoderEvent()

processAccumulatedSteps()

Active component

encoder mode

navigateToNextSection()

highlight arc + centre text

editCurrentItem()

cycle value list

move_up() / move_down()

slide virtual window

LVGL render invalidation

Display Driver

(SPI / RGB / I80)


All components share a two-phase destruction pattern to prevent use-after-free inside LVGL event callbacks:

LVGLComponentParent ScreenLVGLComponentParent ScreenAll event callbacks now check this flag and return earlyprepareForDestruction()is_prepared_for_destruction_ = truecleanup_timer_cb fires (timer-safe)~Destructor()lv_obj_del(container)Free allocated memory\n(sections, nodes, items, etc.)

⚠️ Never destroy component objects directly inside LVGL event callbacks. Always use a cleanup_timer_cb — see screen_transitions_flow.md for the canonical pattern used by all screens.

The is_valid_ flag guards against operating on partially-constructed components in case the constructor throws (e.g. allocation failure on a fragmented heap).


Integration Pattern: Multiple Components on One Screen

Section titled “Integration Pattern: Multiple Components on One Screen”

Example CNC Screen - Core 1

on_press / on_release callbacks

on_press / on_release callbacks

LV_EVENT_CLICKED (buttonmatrix)

create()

CircularMenuComponent

main navigation ring

VirtualButtonsComponent

3 physical buttons

customKeyboard::create()

shown as input overlay on demand

Screen handler

simulate_click / releaseSection

Screen handler

action dispatch

lv_textarea (linked)

ESP3DValues update

(observable system)

Subscriber callbacks

→ UI refresh on value change


TopicDocument
Screen creation, registration, and navigation overviewscreens_architecture.md
Safe timer-based screen transitionsscreen_transitions_flow.md
Hardware encoder / button / switch / potentiometer BSPInput_system.md
Theme tokens, ThemeStyles, apply* style functionsui_style_guide.md
Theme colour tokens (ESP3D_* constants, 26 semantic tokens)theme_palette.md
Image and font resources loaded by componentsdevelopment.md
UIManager, ESP3DXUi, display driver integrationdisplay_drivers.md