Setup and Files app UX improved

This commit is contained in:
Ken Van Hoeylandt 2026-10-15 20:57:59 +02:00
parent bb79f13544
commit 625c8383f3
12 changed files with 223 additions and 23 deletions

View File

@ -1,6 +1,7 @@
# Architecture: LVGL
User interfaces should scale well for everything between very large (e.g. 1280x720) and small (e.g. 135x240) displays. Vertical and horizontal layouts are supported.
Margins and padding are set in density-independent pixels with `LV_DPX()`, not in raw pixels.
Two kernel modules cover LVGL:

View File

@ -7,7 +7,6 @@
## Higher Priority
- Improve Setup: Show progress of some kind (page_indicator?). Improve skip/next buttons UX? e.g. add outline button style and put buttons together? (update docs!)
- BlueTooth as radio backend for MeshCore/Meshtastic?
- ESP-NOW as radio backend. Use it for MeshCore/Meshtastic?
- Find out why the first build of an app with a clean build cache folder always fails
@ -19,7 +18,6 @@
- Use GPS time to set/update the current time: when enabled, at boot, when wifi is not enabled, start the gps device and wait until a lock is acquired, then get the time and sync it. Then stop the GPS device.
- lvgl-module's spinner relies on hard-coded spinner asset from Tactility main project, should be sized based on DPI
- Cursor is hard-coded, needs size based on DPI
- Files app should have a page indicator like Launcher.
- Move Bluetooth code from kernel to Modules/bluetooth-module, and have includes in path bluetooth/ then make a bluetooth_command.cpp like wifi_command.cpp
- Inspect code for memory improvements: replace `std::*` with `tt::std::*`
- esp_lvgl_port settings has a large stack size (~9kB) to fix stackoverflow when LVGL events (e.g. button click) do actions like file operations do actions like file operations. Can we reduce the callstack?

View File

@ -0,0 +1,20 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <lvgl.h>
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Creates a button without a container, for low-emphasis actions such as "Skip" or "Back".
* It behaves like a regular button, but the theme styles it as a text button.
* @param[in] parent the parent object for the new button
* @return the created button
*/
lv_obj_t* lvgl_text_button_create(lv_obj_t* parent);
#ifdef __cplusplus
}
#endif

View File

@ -24,6 +24,7 @@
#include <lvgl/widgets/page_indicator.h>
#include <lvgl/widgets/spinner.h>
#include <lvgl/widgets/statusbar.h>
#include <lvgl/widgets/text_button.h>
#include <lvgl/widgets/toolbar.h>
#include <tactility/module.h>
@ -136,6 +137,8 @@ const struct ModuleSymbol lvgl_module_symbols[] = {
// lvgl_icon_button
DEFINE_MODULE_SYMBOL(lvgl_icon_button_create),
DEFINE_MODULE_SYMBOL(lvgl_icon_button_create_variant),
// lvgl_text_button
DEFINE_MODULE_SYMBOL(lvgl_text_button_create),
// lvgl_list
DEFINE_MODULE_SYMBOL(lvgl_list_create),
DEFINE_MODULE_SYMBOL(lvgl_list_add_text),

View File

@ -22,6 +22,7 @@ extern const lv_obj_class_t lvgl_page_indicator_dot_class;
extern const lv_obj_class_t lvgl_list_class;
extern const lv_obj_class_t lvgl_list_text_class;
extern const lv_obj_class_t lvgl_list_button_class;
extern const lv_obj_class_t lvgl_text_button_class;
#define NEUTRAL_4 lv_color_hex(0x0E0E0E)
#define NEUTRAL_6 lv_color_hex(0x131313)
@ -372,12 +373,9 @@ typedef struct {
lv_style_t button_checked_pressed;
#endif
lv_style_t button_disabled;
#if LV_USE_MSGBOX
lv_style_t text_button;
#endif
#if LV_USE_MSGBOX
lv_style_t text_button_pressed;
#endif
lv_style_t text_button_margin;
lv_style_t icon_button;
lv_style_t icon_button_selected;
lv_style_t icon_button_filled;
@ -857,7 +855,6 @@ static void style_init(my_theme_t * theme)
lv_style_set_text_color(&theme->styles.button_disabled, BUTTON_DISABLED_LABEL_COLOR);
lv_style_set_text_opa(&theme->styles.button_disabled, BUTTON_DISABLED_LABEL_OPACITY);
#if LV_USE_MSGBOX
style_init_reset(&theme->styles.text_button);
lv_style_set_bg_opa(&theme->styles.text_button, LV_OPA_TRANSP);
lv_style_set_text_color(&theme->styles.text_button, TEXT_BUTTON_LABEL_COLOR);
@ -865,13 +862,13 @@ static void style_init(my_theme_t * theme)
lv_style_set_pad_hor(&theme->styles.text_button, TEXT_BUTTON_PAD_HOR);
lv_style_set_pad_ver(&theme->styles.text_button, BUTTON_PAD_VER);
lv_style_set_pad_gap(&theme->styles.text_button, BUTTON_GAP);
#endif
#if LV_USE_MSGBOX
style_init_reset(&theme->styles.text_button_pressed);
lv_style_set_bg_color(&theme->styles.text_button_pressed, TEXT_BUTTON_PRESSED_CONTAINER_COLOR);
lv_style_set_bg_opa(&theme->styles.text_button_pressed, OPACITY_STATE_PRESSED);
#endif
style_init_reset(&theme->styles.text_button_margin);
lv_style_set_margin_all(&theme->styles.text_button_margin, FOCUS_WIDTH + FOCUS_OFFSET);
style_init_reset(&theme->styles.icon_button);
lv_style_set_bg_opa(&theme->styles.icon_button, LV_OPA_TRANSP);
@ -1843,6 +1840,17 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
return;
}
#endif
if(lv_obj_check_type(obj, &lvgl_text_button_class)) {
/* text_button */
lv_obj_add_style(obj, &theme->styles.text_button, 0);
lv_obj_add_style(obj, &theme->styles.text_button_margin, 0);
lv_obj_add_style(obj, &theme->styles.transition_exit, 0);
lv_obj_add_style(obj, &theme->styles.text_button_pressed, LV_STATE_PRESSED);
lv_obj_add_style(obj, &theme->styles.transition_enter, LV_STATE_PRESSED);
lv_obj_add_style(obj, &theme->styles.focus_ring, LV_STATE_FOCUS_KEY);
lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED);
return;
}
#if LV_USE_MSGBOX
if(lv_obj_check_type(obj, &lv_msgbox_footer_button_class)) {
/* msgbox_footer_button */

View File

@ -22,6 +22,7 @@ extern const lv_obj_class_t lvgl_page_indicator_dot_class;
extern const lv_obj_class_t lvgl_list_class;
extern const lv_obj_class_t lvgl_list_text_class;
extern const lv_obj_class_t lvgl_list_button_class;
extern const lv_obj_class_t lvgl_text_button_class;
#define BLACK lv_color_black()
#define WHITE lv_color_white()
@ -237,6 +238,7 @@ typedef struct {
#if LV_USE_BUTTON || LV_USE_MSGBOX
lv_style_t button;
#endif
lv_style_t text_button;
lv_style_t icon_button;
lv_style_t icon_button_filled;
lv_style_t icon_button_filled_pressed;
@ -612,6 +614,16 @@ static void style_init(my_theme_t * theme)
lv_style_set_margin_all(&theme->styles.button, FOCUS_WIDTH + FOCUS_OFFSET);
#endif
style_init_reset(&theme->styles.text_button);
lv_style_set_bg_opa(&theme->styles.text_button, LV_OPA_TRANSP);
lv_style_set_text_color(&theme->styles.text_button, BUTTON_LABEL_COLOR);
lv_style_set_border_width(&theme->styles.text_button, 0);
lv_style_set_radius(&theme->styles.text_button, BUTTON_SHAPE);
lv_style_set_pad_hor(&theme->styles.text_button, BUTTON_PAD_HOR);
lv_style_set_pad_ver(&theme->styles.text_button, BUTTON_PAD_VER);
lv_style_set_pad_gap(&theme->styles.text_button, BUTTON_GAP);
lv_style_set_margin_all(&theme->styles.text_button, FOCUS_WIDTH + FOCUS_OFFSET);
style_init_reset(&theme->styles.icon_button);
lv_style_set_bg_opa(&theme->styles.icon_button, LV_OPA_TRANSP);
lv_style_set_text_color(&theme->styles.icon_button, COLOR_ON_SURFACE);
@ -1415,6 +1427,14 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
return;
}
#endif
if(lv_obj_check_type(obj, &lvgl_text_button_class)) {
/* text_button */
lv_obj_add_style(obj, &theme->styles.text_button, 0);
lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_PRESSED);
lv_obj_add_style(obj, &theme->styles.focus_ring, LV_STATE_FOCUS_KEY);
lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED);
return;
}
#if LV_USE_MSGBOX
if(lv_obj_check_type(obj, &lv_msgbox_footer_button_class)) {
/* msgbox_footer_button */

View File

@ -0,0 +1,26 @@
// SPDX-License-Identifier: Apache-2.0
#define LV_USE_PRIVATE_API 1 // For the lv_obj_class_t declaration
#include <lvgl/widgets/text_button.h>
// Not static: the theme styles text buttons by this class
extern "C" const lv_obj_class_t lvgl_text_button_class = {
.base_class = &lv_button_class,
.constructor_cb = nullptr,
.destructor_cb = nullptr,
.event_cb = nullptr,
.user_data = nullptr,
.name = "text_button",
.width_def = 0,
.height_def = 0,
.editable = LV_OBJ_CLASS_EDITABLE_INHERIT,
.group_def = LV_OBJ_CLASS_GROUP_DEF_INHERIT,
.instance_size = 0,
.theme_inheritable = LV_OBJ_CLASS_THEME_INHERITABLE_FALSE
};
lv_obj_t* lvgl_text_button_create(lv_obj_t* parent) {
lv_obj_t* obj = lv_obj_class_create_obj(&lvgl_text_button_class, parent);
lv_obj_class_init_obj(obj);
return obj;
}

View File

@ -38,6 +38,7 @@
@widget flex_list: lvgl_list_class;
@widget flex_list_text: lvgl_list_text_class;
@widget flex_list_button: lvgl_list_button_class;
@widget text_button: lvgl_text_button_class;
// ============================================================
// 1. Base tokens
@ -686,6 +687,11 @@ text_button_pressed {
bgOpa: @opacity-state-pressed;
}
// Room for the focus ring of a text button, which the parent would clip
text_button_margin {
marginAll: @focus-width + @focus-offset;
}
icon_button {
bgOpa: transp;
textColor: @icon-button-color;
@ -1420,6 +1426,16 @@ apply(msgbox_header_button) {
disabled: state.disabled;
}
apply(text_button) {
text_button: default;
text_button_margin: default;
transition_exit: default;
text_button_pressed: state.pressed;
transition_enter: state.pressed;
focus_ring: state.focusKey;
disabled: state.disabled;
}
apply(msgbox_footer_button) {
text_button: default;
transition_exit: default;

View File

@ -36,6 +36,7 @@
@widget flex_list: lvgl_list_class;
@widget flex_list_text: lvgl_list_text_class;
@widget flex_list_button: lvgl_list_button_class;
@widget text_button: lvgl_text_button_class;
// ============================================================
// 1. Base tokens
@ -465,6 +466,19 @@ button {
marginAll: @focus-width + @focus-offset;
}
// A button without a container, for low-emphasis actions
text_button {
bgOpa: transp;
textColor: @button-label-color;
borderWidth: 0;
radius: @button-shape;
padHor: @button-pad-hor;
padVer: @button-pad-ver;
padGap: @button-gap;
// Room for the focus ring, which the parent would clip
marginAll: @focus-width + @focus-offset;
}
icon_button {
bgOpa: transp;
textColor: @color-on-surface;
@ -1059,6 +1073,13 @@ apply(menu_sidebar_header_cont > button, menu_main_header_cont > button, win > o
focus_ring: state.focusKey;
}
apply(text_button) {
text_button: default;
inverted: state.pressed;
focus_ring: state.focusKey;
disabled: state.disabled;
}
apply(button, msgbox_footer_button) {
button: default;
inverted: state.pressed;

View File

@ -62,7 +62,8 @@ public:
void setSwipeNavigation(bool enabled) { swipeNavigation = enabled; }
/**
* Creates the grid and populates it. Call setPageButtons() right after it to add page buttons.
* Creates the grid and populates it, with a page indicator below it that is hidden when there's only one page.
* Call setPageButtons() right after it to add page buttons.
* @param[in] parent the container for the grid, with a flex column layout
*/
void createWidgets(lv_obj_t* parent);

View File

@ -446,6 +446,11 @@ void TileGrid::createWidgets(lv_obj_t* parent) {
barSideContainer = nullptr;
barButtons.clear();
createGrid(parent);
// Centered below the grid
pageIndicator = lvgl_page_indicator_create(parent);
lv_obj_set_width(pageIndicator, LV_PCT(100));
// Keeps the dots off the screen's bottom edge
lv_obj_set_style_margin_bottom(pageIndicator, lvgl_theme_is_compact() ? LV_DPX(2) : LV_DPX(8), LV_STATE_DEFAULT);
finishCreate(parent);
}

View File

@ -27,8 +27,12 @@
#include <tactility/drivers/wifi.h>
#include <tactility/log.h>
#include <lvgl/devices/indev.h>
#include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/page_indicator.h>
#include <lvgl/widgets/text_button.h>
#include <lvgl.h>
#include <functional>
@ -102,14 +106,36 @@ struct Context {
std::vector<StepConfiguration> steps;
uint32_t pendingStepDialogId = 0;
// Valid while the window is shown
lv_obj_t* titleLabel = nullptr;
lv_obj_t* descriptionLabel = nullptr;
lv_obj_t* backButton = nullptr;
lv_obj_t* skipButton = nullptr;
lv_obj_t* continueButton = nullptr;
lv_obj_t* pageIndicator = nullptr;
};
/** The welcome page, a page per step, and the completion page */
uint32_t getPageCount(const Context* ctx) {
return static_cast<uint32_t>(ctx->steps.size()) + 2;
}
uint32_t getPage(const Context* ctx) {
switch (ctx->phase) {
case Phase::Welcome:
return 0;
case Phase::StepIntro:
return static_cast<uint32_t>(ctx->stepIndex) + 1;
case Phase::Done:
return getPageCount(ctx) - 1;
}
return 0;
}
void renderCurrent(Context* ctx) {
lvgl_page_indicator_set_page(ctx->pageIndicator, getPage(ctx));
lv_obj_set_hidden(ctx->backButton, ctx->phase == Phase::Welcome);
switch (ctx->phase) {
case Phase::Welcome: {
lv_label_set_text(ctx->titleLabel, "Welcome");
@ -146,10 +172,29 @@ void advanceTo(Context* ctx, size_t index) {
}
lvgl_lock();
renderCurrent(ctx);
// Covered by a step's app: createWidgets() renders the current page when the window is shown again
if (ctx->titleLabel != nullptr) {
renderCurrent(ctx);
}
lvgl_unlock();
}
void onBackClicked(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// The previous page: a step's intro page, or the welcome page before the first step
const size_t previousPage = getPage(ctx) - 1;
if (ctx->phase == Phase::Welcome) {
return;
} else if (previousPage == 0) {
ctx->phase = Phase::Welcome;
lvgl_lock();
renderCurrent(ctx);
lvgl_unlock();
} else {
advanceTo(ctx, previousPage - 1);
}
}
void onSkipClicked(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
if (ctx->phase == Phase::StepIntro) {
@ -193,23 +238,59 @@ void createWidgets(lv_obj_t* parent, void* userData) {
int title_margin = lvgl_get_text_font_height(FONT_SIZE_LARGE);
lv_obj_align_to(ctx->titleLabel, ctx->descriptionLabel, LV_ALIGN_OUT_TOP_MID, 0, -title_margin);
ctx->skipButton = lv_button_create(parent);
lv_obj_t* skip_label = lv_label_create(ctx->skipButton);
lv_label_set_text(skip_label, "Skip");
lv_obj_center(skip_label);
lv_obj_align(ctx->skipButton, LV_ALIGN_BOTTOM_LEFT, 12, -12);
// Back on the left, the page indicator in the middle, and Skip next to Continue on the right
auto* buttonRow = lv_obj_create(parent);
lv_obj_remove_style_all(buttonRow);
lv_obj_set_size(buttonRow, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_hor(buttonRow, 8, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(buttonRow, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(buttonRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_align(buttonRow, LV_ALIGN_BOTTOM_MID, 0, -8);
ctx->backButton = lvgl_text_button_create(buttonRow);
lv_label_set_text(lv_label_create(ctx->backButton), "Back");
lv_obj_add_event_cb(ctx->backButton, onBackClicked, LV_EVENT_SHORT_CLICKED, ctx);
auto* spacer = lv_obj_create(buttonRow);
lv_obj_remove_style_all(spacer);
lv_obj_set_height(spacer, 1);
lv_obj_set_flex_grow(spacer, 1);
ctx->skipButton = lvgl_text_button_create(buttonRow);
lv_label_set_text(lv_label_create(ctx->skipButton), "Skip");
lv_obj_add_event_cb(ctx->skipButton, onSkipClicked, LV_EVENT_SHORT_CLICKED, ctx);
ctx->continueButton = lv_button_create(parent);
lv_obj_t* continue_label = lv_label_create(ctx->continueButton);
lv_label_set_text(continue_label, "Continue");
lv_obj_center(continue_label);
lv_obj_align(ctx->continueButton, LV_ALIGN_BOTTOM_RIGHT, -12, -12);
ctx->continueButton = lv_button_create(buttonRow);
lv_label_set_text(lv_label_create(ctx->continueButton), "Continue");
lv_obj_add_event_cb(ctx->continueButton, onContinueClicked, LV_EVENT_SHORT_CLICKED, ctx);
// Centered on the row, outside the flex layout. It shows the page number instead of the dots when they don't fit.
ctx->pageIndicator = lvgl_page_indicator_create(buttonRow);
lv_obj_add_flag(ctx->pageIndicator, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_set_style_max_width(ctx->pageIndicator, LV_PCT(25), LV_STATE_DEFAULT);
lvgl_page_indicator_set_page_count(ctx->pageIndicator, getPageCount(ctx));
lv_obj_align(ctx->pageIndicator, LV_ALIGN_CENTER, 0, 0);
// The arrow keys move between the buttons, and Continue has the focus first
lvgl_grid_navigation_add(buttonRow);
if (!lvgl_indev_exists(LV_INDEV_TYPE_POINTER)) {
lv_group_focus_obj(buttonRow);
lv_gridnav_set_focused(buttonRow, ctx->continueButton, LV_ANIM_OFF);
}
renderCurrent(ctx);
}
void destroyWidgets(void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->titleLabel = nullptr;
ctx->descriptionLabel = nullptr;
ctx->backButton = nullptr;
ctx->skipButton = nullptr;
ctx->continueButton = nullptr;
ctx->pageIndicator = nullptr;
}
int32_t appMain(int argc, char* argv[]) {
uint32_t appInstanceId = app_scheduler_current_app_id();
Context ctx {};
@ -288,7 +369,7 @@ int32_t appMain(int argc, char* argv[]) {
AppEventSubscription sub {};
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {