From 625c8383f3bac0572914ebada7c658a8008dd8cf Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Thu, 15 Oct 2026 20:57:59 +0200 Subject: [PATCH] Setup and Files app UX improved --- .claude/rules/lvgl.md | 1 + Documentation/ideas.md | 2 - .../include/lvgl/widgets/text_button.h | 20 ++++ Modules/lvgl-module/source/symbols.c | 3 + .../source/themes/lv_theme_material.c | 24 ++-- .../source/themes/lv_theme_material_mono.c | 20 ++++ .../source/widgets/text_button.cpp | 26 +++++ Modules/lvgl-module/themes/material.lss | 16 +++ Modules/lvgl-module/themes/material_mono.lss | 21 ++++ Tactility/Private/Tactility/app/TileGrid.h | 3 +- Tactility/Source/app/TileGrid.cpp | 5 + Tactility/Source/app/setup/Setup.cpp | 105 ++++++++++++++++-- 12 files changed, 223 insertions(+), 23 deletions(-) create mode 100644 Modules/lvgl-module/include/lvgl/widgets/text_button.h create mode 100644 Modules/lvgl-module/source/widgets/text_button.cpp diff --git a/.claude/rules/lvgl.md b/.claude/rules/lvgl.md index 6a83501bc..bc7bcc206 100644 --- a/.claude/rules/lvgl.md +++ b/.claude/rules/lvgl.md @@ -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: diff --git a/Documentation/ideas.md b/Documentation/ideas.md index 2431bb108..3ffcdfb34 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -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? diff --git a/Modules/lvgl-module/include/lvgl/widgets/text_button.h b/Modules/lvgl-module/include/lvgl/widgets/text_button.h new file mode 100644 index 000000000..3d1da6562 --- /dev/null +++ b/Modules/lvgl-module/include/lvgl/widgets/text_button.h @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#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 diff --git a/Modules/lvgl-module/source/symbols.c b/Modules/lvgl-module/source/symbols.c index ed5e565ac..4493045f8 100644 --- a/Modules/lvgl-module/source/symbols.c +++ b/Modules/lvgl-module/source/symbols.c @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -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), diff --git a/Modules/lvgl-module/source/themes/lv_theme_material.c b/Modules/lvgl-module/source/themes/lv_theme_material.c index 2df4a83b0..08dbcf93a 100644 --- a/Modules/lvgl-module/source/themes/lv_theme_material.c +++ b/Modules/lvgl-module/source/themes/lv_theme_material.c @@ -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 */ diff --git a/Modules/lvgl-module/source/themes/lv_theme_material_mono.c b/Modules/lvgl-module/source/themes/lv_theme_material_mono.c index c8e8458cb..607040fba 100644 --- a/Modules/lvgl-module/source/themes/lv_theme_material_mono.c +++ b/Modules/lvgl-module/source/themes/lv_theme_material_mono.c @@ -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 */ diff --git a/Modules/lvgl-module/source/widgets/text_button.cpp b/Modules/lvgl-module/source/widgets/text_button.cpp new file mode 100644 index 000000000..e9256ee5d --- /dev/null +++ b/Modules/lvgl-module/source/widgets/text_button.cpp @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +#define LV_USE_PRIVATE_API 1 // For the lv_obj_class_t declaration + +#include + +// 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; +} diff --git a/Modules/lvgl-module/themes/material.lss b/Modules/lvgl-module/themes/material.lss index 086b38601..a7357f7c9 100644 --- a/Modules/lvgl-module/themes/material.lss +++ b/Modules/lvgl-module/themes/material.lss @@ -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; diff --git a/Modules/lvgl-module/themes/material_mono.lss b/Modules/lvgl-module/themes/material_mono.lss index ef01da7b1..5fbbb719b 100644 --- a/Modules/lvgl-module/themes/material_mono.lss +++ b/Modules/lvgl-module/themes/material_mono.lss @@ -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; diff --git a/Tactility/Private/Tactility/app/TileGrid.h b/Tactility/Private/Tactility/app/TileGrid.h index 72ceb3f2f..05be0d8b6 100644 --- a/Tactility/Private/Tactility/app/TileGrid.h +++ b/Tactility/Private/Tactility/app/TileGrid.h @@ -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); diff --git a/Tactility/Source/app/TileGrid.cpp b/Tactility/Source/app/TileGrid.cpp index 185716d1a..01c0496db 100644 --- a/Tactility/Source/app/TileGrid.cpp +++ b/Tactility/Source/app/TileGrid.cpp @@ -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); } diff --git a/Tactility/Source/app/setup/Setup.cpp b/Tactility/Source/app/setup/Setup.cpp index 158c18fbd..a8dcf316d 100644 --- a/Tactility/Source/app/setup/Setup.cpp +++ b/Tactility/Source/app/setup/Setup.cpp @@ -27,8 +27,12 @@ #include #include +#include #include +#include #include +#include +#include #include #include @@ -102,14 +106,36 @@ struct Context { std::vector 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(ctx->steps.size()) + 2; +} + +uint32_t getPage(const Context* ctx) { + switch (ctx->phase) { + case Phase::Welcome: + return 0; + case Phase::StepIntro: + return static_cast(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(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(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(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) {