UI Components Module
Section titled “UI Components Module”Introduction
Section titled “Introduction”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.
Architecture Overview
Section titled “Architecture Overview”Component Hierarchy
Section titled “Component Hierarchy”Components
Section titled “Components”1. RotaryBaseComponent
Section titled “1. RotaryBaseComponent”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→processEncoderStepper step →onEncoderProcessingEnd - Stores
screen_id_so derived classes can persist per-screen state
Design contract for derived classes:
| Method | Required | Purpose |
|---|---|---|
processEncoderStep(direction, step_number, total_steps) | Yes | Handle one encoder tick |
onEncoderProcessingStart(direction, total_steps) | No | Pre-batch UI prep (e.g. suppress animations) |
onEncoderProcessingEnd(direction, total_steps) | No | Post-batch finalisation (e.g. persist state) |
Encoder event dispatch flow:
2. CircularMenuComponent
Section titled “2. CircularMenuComponent”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:
| Macro | Description |
|---|---|
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 descriptortypedef 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:
MenuColorType | Normal state | Pressed state |
|---|---|---|
Theme | ESP3D_MENU_ICON_COLOR (theme token) | Lighter theme shade |
CustomDarker | color field | Darker shade of color field |
Section lifecycle:
Encoder index modes:
encoder_enabled_index_ | Behaviour |
|---|---|
ESP3D_MENU_SELECTION (0) | Encoder rotates the selection between sections |
| Other index | Encoder 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().
3. ListMenuComponent
Section titled “3. ListMenuComponent”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:
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 containervoid (*display_cb)(lv_obj_t *node_container, const void *item, void *user_data, void *extra);
// Called when an item is clicked / confirmedvoid (*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:
| API | Effect |
|---|---|
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 |
4. PanelComponent
Section titled “4. PanelComponent”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:
| Constant | Value | Encoder behaviour |
|---|---|---|
PANEL_NAVIGATION_MODE | 0 | Move focus between items |
PANEL_EDITING_MODE | 1 | Cycle 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:
a14 changes:
setFocusedItem(item_id, silent = false):silent = trueskips the focus-change beep. Screens use it when they restore the focus at creation, afteraddItem()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-levelstaticvalues 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.
5. VirtualButtonsComponent
Section titled “5. VirtualButtonsComponent”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:
| Macro | Position | HW 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) | Left | 0 — pre-wired OK icon |
BUTTON_x_EMPTY() | Any | -1 (no HW mapping) |
Colour modes:
ButtonColorType | Description |
|---|---|
Theme | Uses ESP3D_MENU_ICON_COLOR theme token |
CustomDarker | Custom RGB; pressed = darker shade |
CustomFilled | Custom RGB with filled background |
Touch-only emulation static helpers:
| Method | Purpose |
|---|---|
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:
6. customKeyboard (Namespace)
Section titled “6. customKeyboard (Namespace)”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:
KeyboardType | Rows | Description |
|---|---|---|
NumericIntPos | 4 | 0–9, backspace, cursor arrows |
NumericFloatNeg | 4 | 0–9, ., -, backspace, cursor arrows |
NumericListFloat | 4 | 0–9, ., ; list separator, backspace, cursor arrows |
NumericListInt | 4 | 0–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:
Button visual classes:
| Class | Background | Text colour | When applied |
|---|---|---|---|
| Normal character | ESP3D_SCREEN_BACKGROUND_COLOR (dark) | ESP3D_SCREEN_BACKGROUND_TEXT_COLOR (light) | Default state |
| System button | ESP3D_SCREEN_BACKGROUND_TEXT_COLOR (light) | ESP3D_SCREEN_BACKGROUND_COLOR (dark) | Backspace, arrows, ABC, abc, 1#, space, ;, mm, in |
| Pressed (any) | ESP3D_ACCENT_SELECT_COLOR | Light | LV_STATE_PRESSED |
Numeric input validation (validate_numeric_input) — rules applied in order after each keystroke:
- Empty input →
"0"(unless PIN mode /allow_leading_zeros) - Remove duplicate decimal points (keep first)
- Leading
.→ prefix with"0"(e.g."."→"0.") - Leading
"-."→ change to"-0." - Strip leading zeros unless preceding
.or in PIN mode (e.g."01"→"1","0.5"stays) "-"alone →"-0"
finalize_numeric_input(textarea, allow_leading_zeros) — normalises edge cases on OK press:
| Input | Output |
|---|---|
"", ".", "-." | "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 |
Data Flow: Encoder Event to UI Update
Section titled “Data Flow: Encoder Event to UI Update”Lifecycle and Destruction Safety
Section titled “Lifecycle and Destruction Safety”All components share a two-phase destruction pattern to prevent use-after-free inside LVGL event callbacks:
⚠️ Never destroy component objects directly inside LVGL event callbacks. Always use a
cleanup_timer_cb— seescreen_transitions_flow.mdfor 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”Cross-References
Section titled “Cross-References”| Topic | Document |
|---|---|
| Screen creation, registration, and navigation overview | screens_architecture.md |
| Safe timer-based screen transitions | screen_transitions_flow.md |
| Hardware encoder / button / switch / potentiometer BSP | Input_system.md |
Theme tokens, ThemeStyles, apply* style functions | ui_style_guide.md |
Theme colour tokens (ESP3D_* constants, 26 semantic tokens) | theme_palette.md |
| Image and font resources loaded by components | development.md |
UIManager, ESP3DXUi, display driver integration | display_drivers.md |