diff --git a/Buildscripts/TactilitySDK/TactilitySDK.esp32.cmake b/Buildscripts/TactilitySDK/TactilitySDK.esp32.cmake index b1da6de34..0c0af1347 100644 --- a/Buildscripts/TactilitySDK/TactilitySDK.esp32.cmake +++ b/Buildscripts/TactilitySDK/TactilitySDK.esp32.cmake @@ -24,6 +24,12 @@ macro(tactility_project_post project_name) idf_build_set_property(COMPILE_OPTIONS "-fno-omit-frame-pointer" APPEND) endif() + # Binds the app's references to its own symbols at link time. The C++ standard library's templates + # (e.g. std::shared_ptr internals) keep default visibility despite -fvisibility=hidden, so without this + # the linker leaves symbol relocations for them, which the ELF loader only resolves against the + # firmware's exported symbols. On RISC-V that fails to load the app with "Can't find common ". + idf_build_set_property(COMPILE_OPTIONS "-Wl,-Bsymbolic" APPEND) + # ESP-IDF's own (esp_libc/platform_include) is found before app-module's, which adds # the terminal ioctls (TIOCGWINSZ) Tactility's ioctl() supports for app fds. # C and C++ only: assembly sources can't include a C header. diff --git a/Devices/waveshare-s3-touch-lcd-128/waveshare,s3-touch-lcd-128.dts b/Devices/waveshare-s3-touch-lcd-128/waveshare,s3-touch-lcd-128.dts index 812e33086..1d9faa7b2 100644 --- a/Devices/waveshare-s3-touch-lcd-128/waveshare,s3-touch-lcd-128.dts +++ b/Devices/waveshare-s3-touch-lcd-128/waveshare,s3-touch-lcd-128.dts @@ -84,6 +84,7 @@ compatible = "galaxycore,gc9a01"; horizontal-resolution = <240>; vertical-resolution = <240>; + shape = ; mirror-y; invert-color; bgr-order; diff --git a/Devices/waveshare-s3-touch-lcd-147/waveshare,s3-touch-lcd-147.dts b/Devices/waveshare-s3-touch-lcd-147/waveshare,s3-touch-lcd-147.dts index fcad9fd57..df7bfebf9 100644 --- a/Devices/waveshare-s3-touch-lcd-147/waveshare,s3-touch-lcd-147.dts +++ b/Devices/waveshare-s3-touch-lcd-147/waveshare,s3-touch-lcd-147.dts @@ -77,6 +77,9 @@ compatible = "jadard,jd9853"; horizontal-resolution = <172>; vertical-resolution = <320>; + // The panel has rounded corners. The radius is estimated, as it isn't specified. + shape = ; + corner-radius = <40>; gap-x = <34>; invert-color; pin-dc = <&gpio0 45 GPIO_FLAG_NONE>; diff --git a/Drivers/gc9a01-module/bindings/galaxycore,gc9a01.yaml b/Drivers/gc9a01-module/bindings/galaxycore,gc9a01.yaml index 9fa38debe..cc815a2a2 100644 --- a/Drivers/gc9a01-module/bindings/galaxycore,gc9a01.yaml +++ b/Drivers/gc9a01-module/bindings/galaxycore,gc9a01.yaml @@ -2,6 +2,8 @@ description: Galaxycore GC9A01 round display panel bus: spi +include: ["display.yaml"] + compatible: "galaxycore,gc9a01" properties: diff --git a/Drivers/gc9a01-module/include/drivers/gc9a01.h b/Drivers/gc9a01-module/include/drivers/gc9a01.h index 233811faf..03486f3b1 100644 --- a/Drivers/gc9a01-module/include/drivers/gc9a01.h +++ b/Drivers/gc9a01-module/include/drivers/gc9a01.h @@ -9,9 +9,13 @@ extern "C" { #include #include +#include #include struct Gc9a01Config { + // From display.yaml: a DisplayShapeType value and the corner radius for rounded displays + uint8_t shape; + uint16_t corner_radius; uint16_t horizontal_resolution; uint16_t vertical_resolution; int32_t gap_x; diff --git a/Drivers/gc9a01-module/source/gc9a01.cpp b/Drivers/gc9a01-module/source/gc9a01.cpp index ca89764dc..a2d956c2a 100644 --- a/Drivers/gc9a01-module/source/gc9a01.cpp +++ b/Drivers/gc9a01-module/source/gc9a01.cpp @@ -316,6 +316,11 @@ static error_t gc9a01_get_backlight(Device* device, Device** backlight) { // endregion +static void gc9a01_get_shape(Device* device, DisplayShape* shape) { + const auto* config = GET_CONFIG(device); + *shape = { .shape = config->shape, .corner_radius = config->corner_radius }; +} + static const DisplayApi gc9a01_display_api = { .capabilities = DISPLAY_CAPABILITY_CAP_MIRROR | DISPLAY_CAPABILITY_CAP_SWAP_XY | DISPLAY_CAPABILITY_CAP_SET_GAP | DISPLAY_CAPABILITY_INVERT_COLOR | DISPLAY_CAPABILITY_ON_OFF | @@ -343,6 +348,7 @@ static const DisplayApi gc9a01_display_api = { .get_frame_buffer_count = nullptr, .get_backlight = gc9a01_get_backlight, .has_capability = nullptr, + .get_shape = gc9a01_get_shape, }; Driver gc9a01_driver = { diff --git a/Drivers/jd9853-module/bindings/jadard,jd9853.yaml b/Drivers/jd9853-module/bindings/jadard,jd9853.yaml index c7ae39874..7bcc4d86f 100644 --- a/Drivers/jd9853-module/bindings/jadard,jd9853.yaml +++ b/Drivers/jd9853-module/bindings/jadard,jd9853.yaml @@ -2,6 +2,8 @@ description: Jadard JD9853 display panel bus: spi +include: ["display.yaml"] + compatible: "jadard,jd9853" properties: diff --git a/Drivers/jd9853-module/include/drivers/jd9853.h b/Drivers/jd9853-module/include/drivers/jd9853.h index 435b77d4a..daaed2eda 100644 --- a/Drivers/jd9853-module/include/drivers/jd9853.h +++ b/Drivers/jd9853-module/include/drivers/jd9853.h @@ -9,9 +9,13 @@ extern "C" { #include #include +#include #include struct Jd9853Config { + // From display.yaml: a DisplayShapeType value and the corner radius for rounded displays + uint8_t shape; + uint16_t corner_radius; uint16_t horizontal_resolution; uint16_t vertical_resolution; int32_t gap_x; diff --git a/Drivers/jd9853-module/source/jd9853.cpp b/Drivers/jd9853-module/source/jd9853.cpp index 09ac98f98..03cf9a92b 100644 --- a/Drivers/jd9853-module/source/jd9853.cpp +++ b/Drivers/jd9853-module/source/jd9853.cpp @@ -329,6 +329,11 @@ static error_t jd9853_get_backlight(Device* device, Device** backlight) { // endregion +static void jd9853_get_shape(Device* device, DisplayShape* shape) { + const auto* config = GET_CONFIG(device); + *shape = { .shape = config->shape, .corner_radius = config->corner_radius }; +} + static const DisplayApi jd9853_display_api = { .capabilities = DISPLAY_CAPABILITY_CAP_MIRROR | DISPLAY_CAPABILITY_CAP_SWAP_XY | DISPLAY_CAPABILITY_CAP_SET_GAP | DISPLAY_CAPABILITY_INVERT_COLOR | DISPLAY_CAPABILITY_ON_OFF | @@ -356,6 +361,7 @@ static const DisplayApi jd9853_display_api = { .get_frame_buffer_count = nullptr, .get_backlight = jd9853_get_backlight, .has_capability = nullptr, + .get_shape = jd9853_get_shape, }; Driver jd9853_driver = { diff --git a/LICENSE.md b/LICENSE.md index c59ce9e79..e8759e701 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -13,7 +13,7 @@ These applications are not part of the Tactility operating system's main firmwar The intentions behind picking the licenses for the subprojects: -1. Forks of the entire Tactility project required to be open source as: GPL v3.0 applies to the main firmware projects (see `Firmware/` and `Tactility/`). +1. Forks of the entire Tactility project required to be open source as: GPL v3.0 applies to the main firmware project (see `Tactility/`). 2. It should be possible to make closed source external applications using TactilitySDK and the libraries it includes (it must exclude software with a GPL license). 3. It should be possible to make closed source forks that only contain TactilityKernel, the platform implementations (`Platforms/*`) and most of the device and driver implementations. As few as possible driver and device combinations should prevent this. @@ -30,12 +30,12 @@ The license texts that are relevant to this document: **IMPORTANT:** This document may have inaccuracies. It mainly exists to create awareness about license differences. -The top-level projects `Firmware/` and `Tactility/` are licensed under `GPL v3.0`. +The top-level project `Tactility/` is licensed under `GPL v3.0`. Most devices are licensed with `Apache License v2.0` while some are implemented as `GPL v3.0` until author consent is given to change the license. New device implementations should be licensed under `Apache License v2.0`. -Most drivers have an `Apache License v2.0`, with exceptions such as `Drivers/gps-generic-module/`. +Most drivers have an `Apache License v2.0`, with exceptions such as `Drivers/gps-meshtastic-module/`. Licensing may also differ for subprojects intended for use in external applications. Driver subprojects aren't generally used directly in external app projects, but if they are, make sure to check their licenses. @@ -65,7 +65,6 @@ Below is an overview of the licenses of some of the subprojects. | Project | License | |--------------------|-------------------------| | Tactility | GNU Public License v3.0 | -| TactilityC | Apache License v2.0 | | TactilityFreeRTOS | Apache License v2.0 | | TactilityKernel | Apache License v2.0 | | Tests | (varies) | diff --git a/Modules/app-module/include/app/manifest.h b/Modules/app-module/include/app/manifest.h index 0b81bb733..efe4ae306 100644 --- a/Modules/app-module/include/app/manifest.h +++ b/Modules/app-module/include/app/manifest.h @@ -28,7 +28,7 @@ enum AppCategory { /** Bit flags for AppManifest::flags. */ enum AppManifestFlags { - /** Excluded from generic app-browsing UIs (e.g. AppList, Settings) */ + /** Excluded from generic app-browsing UIs (e.g. Launcher, Settings) */ APP_MANIFEST_FLAG_HIDDEN = 1 << 0, /** No window-manager dependency. Safe to start from a context with no GUI available. */ APP_MANIFEST_FLAG_HEADLESS = 1 << 1, diff --git a/Modules/lvgl-module/generate-icons.py b/Modules/lvgl-module/generate-icons.py index 2306ae811..6712aa99c 100755 --- a/Modules/lvgl-module/generate-icons.py +++ b/Modules/lvgl-module/generate-icons.py @@ -183,6 +183,7 @@ shared_symbol_code_point_names = [ statusbar_symbol_code_point_names = [ # Location tracking "location_on", + "location_off", # Development server "cloud", # Low on available memory diff --git a/Modules/lvgl-module/include/lvgl/devices/trackball.h b/Modules/lvgl-module/include/lvgl/devices/trackball.h index 15e37d1d8..e3405a8b8 100644 --- a/Modules/lvgl-module/include/lvgl/devices/trackball.h +++ b/Modules/lvgl-module/include/lvgl/devices/trackball.h @@ -17,8 +17,8 @@ extern "C" { * @brief Trackball operating mode. */ enum LvglTrackballMode { - /** Navigation via enc_diff (scroll wheel behavior). No cursor is shown. */ - LVGL_TRACKBALL_MODE_ENCODER, + /** Navigation with arrow keys, and the button as the enter key. No cursor is shown. */ + LVGL_TRACKBALL_MODE_KEYS, /** Mouse cursor via point.x/point.y. A cursor is shown if an image was set via * lvgl_trackball_set_cursor_image(). */ LVGL_TRACKBALL_MODE_POINTER @@ -33,8 +33,8 @@ struct LvglTrackballSettings { /** When false, movement and button presses are drained from the device and discarded: * the indev reports no movement and stays released. */ bool enabled; - /** Encoder mode: steps per raw trackball tick. Must be >= 1. */ - uint8_t encoder_sensitivity; + /** Keys mode: arrow key presses per raw trackball tick. Must be >= 1. */ + uint8_t key_sensitivity; /** Pointer mode: pixels per raw trackball tick. Must be >= 1. */ uint8_t pointer_sensitivity; }; @@ -43,7 +43,7 @@ struct LvglTrackballSettings { * @brief Creates an lv_indev_t bound to the given TRACKBALL_TYPE device and registers a read * callback that polls the device through its TrackballApi. * - * The indev starts in LVGL_TRACKBALL_MODE_ENCODER, enabled, with default sensitivities (see + * The indev starts in LVGL_TRACKBALL_MODE_KEYS, enabled, with default sensitivities (see * lvgl_trackball_settings_get_default()). Call lvgl_trackball_set_settings() to change this, * e.g. to switch to pointer mode - each device added this way can independently run with or * without pointer support. @@ -68,8 +68,8 @@ error_t lvgl_trackball_add(struct Device* device, lv_display_t* display, lv_inde void lvgl_trackball_remove(lv_indev_t* indev); /** - * @return the default (all fields explicitly set) LvglTrackballSettings: encoder mode, enabled, - * encoder_sensitivity 1, pointer_sensitivity 10. + * @return the default (all fields explicitly set) LvglTrackballSettings: keys mode, enabled, + * key_sensitivity 1, pointer_sensitivity 10. */ struct LvglTrackballSettings lvgl_trackball_settings_get_default(void); diff --git a/Modules/lvgl-module/include/lvgl/grid_navigation.h b/Modules/lvgl-module/include/lvgl/grid_navigation.h index 36bc27797..1319c4fab 100644 --- a/Modules/lvgl-module/include/lvgl/grid_navigation.h +++ b/Modules/lvgl-module/include/lvgl/grid_navigation.h @@ -21,12 +21,12 @@ void lvgl_grid_navigation_add(lv_obj_t* container); void lvgl_grid_navigation_remove(lv_obj_t* container); /** - * @brief Moves the focus to the next or previous child in their order, for devices that can only step through widgets. - * Past the last or first child, the focus moves to the next or previous object in the container's focus group. - * @param[in] container a container that was passed to lvgl_grid_navigation_add() - * @param[in] next true for the next child, false for the previous child + * @brief Moves the focus to the next or previous widget, for devices that can only step through widgets. + * The children of grid navigation containers are visited in their order: entering a container forwards focuses its first child, and backwards its last child. + * @param[in] group the focus group + * @param[in] next true for the next widget, false for the previous widget */ -void lvgl_grid_navigation_focus_next(lv_obj_t* container, bool next); +void lvgl_grid_navigation_step(lv_group_t* group, bool next); /** * @param[in] container a container that was passed to lvgl_grid_navigation_add() diff --git a/Modules/lvgl-module/include/lvgl/icons/names.h b/Modules/lvgl-module/include/lvgl/icons/names.h index 688a98965..027f87720 100644 --- a/Modules/lvgl-module/include/lvgl/icons/names.h +++ b/Modules/lvgl-module/include/lvgl/icons/names.h @@ -9,7 +9,7 @@ extern "C" { #endif /** Changes whenever the icon TTF subset, its codepoints file or the icon name lists change */ -#define LVGL_ICON_FONT_VERSION 0xEE67835Du +#define LVGL_ICON_FONT_VERSION 0x1B608834u // Icon names per icon font, resolved through the .codepoints file next to the TTF extern const char* const lvgl_icon_shared_names[]; diff --git a/Modules/lvgl-module/include/lvgl/icons/statusbar.h b/Modules/lvgl-module/include/lvgl/icons/statusbar.h index fa1a2d3ec..48b73925d 100644 --- a/Modules/lvgl-module/include/lvgl/icons/statusbar.h +++ b/Modules/lvgl-module/include/lvgl/icons/statusbar.h @@ -3,6 +3,7 @@ #pragma once #define LVGL_ICON_STATUSBAR_LOCATION_ON "\xEF\x87\x9B" +#define LVGL_ICON_STATUSBAR_LOCATION_OFF "\xEE\x83\x87" #define LVGL_ICON_STATUSBAR_CLOUD "\xEF\x85\x9C" #define LVGL_ICON_STATUSBAR_MEMORY "\xEE\x8C\xA2" #define LVGL_ICON_STATUSBAR_SD_CARD "\xEE\x98\xA3" diff --git a/Modules/lvgl-module/include/lvgl/insets.h b/Modules/lvgl-module/include/lvgl/insets.h new file mode 100644 index 000000000..87ecfa85d --- /dev/null +++ b/Modules/lvgl-module/include/lvgl/insets.h @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** Margins in pixels on each side of a display */ +typedef struct { + int32_t top; + int32_t bottom; + int32_t left; + int32_t right; +} LvglInsets; + +/** + * @brief Gets the shape of the display's visible area. + * @param[in] display the display + * @param[out] out_shape the shape: a rectangle when the display doesn't report one + */ +void lvgl_display_get_shape(lv_display_t* display, struct DisplayShape* out_shape); + +/** + * @brief Gets the margins that keep a rectangle inside the display's visible area. + * They're all zero for rectangular displays. + * @param[in] display the display + * @param[out] out_insets the margins + */ +void lvgl_display_get_insets(lv_display_t* display, LvglInsets* out_insets); + +/** + * @brief Gets the horizontal margin on both sides of a row, for layouts that follow the display's edge. + * @param[in] display the display + * @param[in] y the row, in display coordinates + * @return the margin in pixels on the left and on the right of the row + */ +int32_t lvgl_display_get_row_inset(lv_display_t* display, int32_t y); + +/** + * @brief Moves the object's content inward where it overlaps the curved edge of a round or rounded display. + * The extra horizontal padding is added to the object's current padding, and follows its size changes. + * Does nothing on rectangular displays. + * @param[in] obj the object, with its own padding already set + */ +void lvgl_obj_add_edge_padding(lv_obj_t* obj); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/lvgl-module/private/lvgl/devices/keyboard_private.h b/Modules/lvgl-module/private/lvgl/devices/keyboard_private.h index 21d1c3658..3db773c51 100644 --- a/Modules/lvgl-module/private/lvgl/devices/keyboard_private.h +++ b/Modules/lvgl-module/private/lvgl/devices/keyboard_private.h @@ -4,9 +4,22 @@ extern "C" { #endif +#include + +#include + void lvgl_keyboard_on_start_lvgl(); void lvgl_keyboard_on_stop_lvgl(); +/** + * @brief Translates a key codepoint into the LVGL key for the object that the indev's group focuses. + * The arrow up and down keys move the focus to the previous and next widget, or within a grid navigation container. + * @param[in] indev the indev that reports the key + * @param[in] codepoint the key codepoint (see tactility/drivers/keyboard.h) + * @return the LVGL key + */ +uint32_t lvgl_keyboard_translate_key(lv_indev_t* indev, uint32_t codepoint); + #ifdef __cplusplus } #endif diff --git a/Modules/lvgl-module/private/lvgl/insets_math.h b/Modules/lvgl-module/private/lvgl/insets_math.h new file mode 100644 index 000000000..ca2b68f88 --- /dev/null +++ b/Modules/lvgl-module/private/lvgl/insets_math.h @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +/** Calculates the insets of a display with the given shape and resolution */ +LvglInsets lvgl_insets_calculate(const DisplayShape& shape, int32_t width, int32_t height); + +/** Calculates the horizontal margin of a row of a display with the given shape and resolution */ +int32_t lvgl_insets_calculate_row(const DisplayShape& shape, int32_t width, int32_t height, int32_t y); diff --git a/Modules/lvgl-module/source/devices/keyboard.cpp b/Modules/lvgl-module/source/devices/keyboard.cpp index 11fd2d09f..8c917ecb3 100644 --- a/Modules/lvgl-module/source/devices/keyboard.cpp +++ b/Modules/lvgl-module/source/devices/keyboard.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 #include #include +#include #include #include @@ -56,14 +57,26 @@ static uint32_t codepoint_to_lv_key(uint32_t key) { case CODEPOINT_ARROW_UP: return LV_KEY_PREV; case CODEPOINT_ARROW_RIGHT: return LV_KEY_RIGHT; case CODEPOINT_ARROW_DOWN: return LV_KEY_NEXT; - case CODEPOINT_FOCUS_NEXT: return LV_KEY_NEXT; - case CODEPOINT_FOCUS_PREVIOUS: return LV_KEY_PREV; case CODEPOINT_HOME: return LV_KEY_HOME; case CODEPOINT_END: return LV_KEY_END; default: return key; } } +uint32_t lvgl_keyboard_translate_key(lv_indev_t* indev, uint32_t codepoint) { + lv_group_t* group = lv_indev_get_group(indev); + lv_obj_t* focused = group != nullptr ? lv_group_get_focused(group) : nullptr; + if (lvgl_grid_navigation_is_container(focused)) { + // Grid navigation moves the focus in two dimensions with the arrow keys + if (codepoint == CODEPOINT_ARROW_UP) { + return LV_KEY_UP; + } else if (codepoint == CODEPOINT_ARROW_DOWN) { + return LV_KEY_DOWN; + } + } + return codepoint_to_lv_key(codepoint); +} + static void lvgl_keyboard_read_cb(lv_indev_t* indev, lv_indev_data_t* data) { auto* wrapper = static_cast(lv_indev_get_driver_data(indev)); @@ -74,26 +87,19 @@ static void lvgl_keyboard_read_cb(lv_indev_t* indev, lv_indev_data_t* data) { return; } - data->key = codepoint_to_lv_key(key_data.key); lv_group_t* group = lv_indev_get_group(indev); - lv_obj_t* focused = group != nullptr ? lv_group_get_focused(group) : nullptr; - if (lvgl_grid_navigation_is_container(focused)) { - if (key_data.key == CODEPOINT_ARROW_UP) { - // Grid navigation moves the focus in two dimensions with the arrow keys - data->key = LV_KEY_UP; - } else if (key_data.key == CODEPOINT_ARROW_DOWN) { - data->key = LV_KEY_DOWN; - } else if (key_data.key == CODEPOINT_FOCUS_NEXT || key_data.key == CODEPOINT_FOCUS_PREVIOUS) { - // Devices that can only step through widgets visit the children in their order. - // LVGL doesn't see these keys: the group would skip the children, as only the container is in the group. - if (key_data.pressed) { - lvgl_grid_navigation_focus_next(focused, key_data.key == CODEPOINT_FOCUS_NEXT); - } - data->state = LV_INDEV_STATE_RELEASED; - data->continue_reading = key_data.continue_reading; - return; + if (key_data.key == CODEPOINT_FOCUS_NEXT || key_data.key == CODEPOINT_FOCUS_PREVIOUS) { + // Devices that can only step through widgets also visit the children of grid navigation containers. + // LVGL doesn't see these keys: the group would skip the children, as only the container is in the group. + if (key_data.pressed && group != nullptr) { + lvgl_grid_navigation_step(group, key_data.key == CODEPOINT_FOCUS_NEXT); } + data->state = LV_INDEV_STATE_RELEASED; + data->continue_reading = key_data.continue_reading; + return; } + + data->key = lvgl_keyboard_translate_key(indev, key_data.key); data->state = key_data.pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; data->continue_reading = key_data.continue_reading; } diff --git a/Modules/lvgl-module/source/devices/trackball.cpp b/Modules/lvgl-module/source/devices/trackball.cpp index 6a75aeeac..0d1d6d982 100644 --- a/Modules/lvgl-module/source/devices/trackball.cpp +++ b/Modules/lvgl-module/source/devices/trackball.cpp @@ -2,11 +2,15 @@ #include #include #include +#include #include +#include #include +#include #include +#include constexpr auto* TAG = "lvgl_trackball"; @@ -16,6 +20,11 @@ struct LvglTrackballCtx { int32_t cursor_y; lv_obj_t* cursor; const void* cursor_image_src; + // Keys mode: arrow key presses that are still to be reported + uint32_t pending_codepoint; + int32_t pending_count; + // Keys mode: the LVGL key that is reported as pressed, or 0 + uint32_t pressed_key; }; static inline int32_t clamp(int32_t value, int32_t min_value, int32_t max_value) { @@ -68,6 +77,49 @@ static void hide_cursor(LvglTrackballCtx* ctx) { lv_obj_add_flag(ctx->cursor, LV_OBJ_FLAG_HIDDEN); } +// Reports one key event per read: the button as the enter key, and movement as arrow key presses along its strongest axis. +static void read_keys(lv_indev_t* indev, LvglTrackballCtx* ctx, lv_indev_data_t* data, int32_t dx, int32_t dy, bool button_pressed) { + if (dx != 0 || dy != 0) { + uint32_t codepoint; + if (std::abs(dx) >= std::abs(dy)) { + codepoint = dx > 0 ? CODEPOINT_ARROW_RIGHT : CODEPOINT_ARROW_LEFT; + } else { + codepoint = dy > 0 ? CODEPOINT_ARROW_DOWN : CODEPOINT_ARROW_UP; + } + const int32_t steps = std::max(std::abs(dx), std::abs(dy)) * static_cast(ctx->settings.key_sensitivity); + // A change of direction drops the presses that weren't reported yet + if (codepoint != ctx->pending_codepoint) { + ctx->pending_codepoint = codepoint; + ctx->pending_count = 0; + } + ctx->pending_count = clamp(ctx->pending_count + steps, 0, INT16_MAX); + lv_display_trigger_activity(lv_indev_get_display(indev)); + } + + if (ctx->pressed_key == LV_KEY_ENTER && button_pressed) { + data->key = LV_KEY_ENTER; + data->state = LV_INDEV_STATE_PRESSED; + } else if (ctx->pressed_key != 0) { + // Every press is released before the next event, so LVGL sees each arrow key press + data->key = ctx->pressed_key; + data->state = LV_INDEV_STATE_RELEASED; + ctx->pressed_key = 0; + data->continue_reading = ctx->pending_count > 0; + } else if (button_pressed) { + ctx->pressed_key = LV_KEY_ENTER; + data->key = LV_KEY_ENTER; + data->state = LV_INDEV_STATE_PRESSED; + lv_display_trigger_activity(lv_indev_get_display(indev)); + } else if (ctx->pending_count > 0) { + ctx->pending_count--; + ctx->pressed_key = lvgl_keyboard_translate_key(indev, ctx->pending_codepoint); + data->key = ctx->pressed_key; + data->state = LV_INDEV_STATE_PRESSED; + } else { + data->state = LV_INDEV_STATE_RELEASED; + } +} + static void lvgl_trackball_read_cb(lv_indev_t* indev, lv_indev_data_t* data) { auto* wrapper = static_cast(lv_indev_get_driver_data(indev)); auto* ctx = static_cast(wrapper->context); @@ -83,25 +135,22 @@ static void lvgl_trackball_read_cb(lv_indev_t* indev, lv_indev_data_t* data) { lv_display_t* display = lv_indev_get_display(indev); - if (ctx->settings.mode == LVGL_TRACKBALL_MODE_ENCODER) { - int32_t ticks = (dx + dy) * static_cast(ctx->settings.encoder_sensitivity); - data->enc_diff = static_cast(clamp(ticks, INT16_MIN, INT16_MAX)); - if (ticks != 0) { - lv_display_trigger_activity(display); - } - } else { - int32_t max_x = display != nullptr ? lv_display_get_original_horizontal_resolution(display) - 1 : 0; - int32_t max_y = display != nullptr ? lv_display_get_original_vertical_resolution(display) - 1 : 0; - ctx->cursor_x = clamp(ctx->cursor_x + dx * static_cast(ctx->settings.pointer_sensitivity), 0, max_x); - ctx->cursor_y = clamp(ctx->cursor_y + dy * static_cast(ctx->settings.pointer_sensitivity), 0, max_y); - data->point.x = static_cast(ctx->cursor_x); - data->point.y = static_cast(ctx->cursor_y); - } - bool pressed = false; if (ctx->settings.enabled) { trackball_get_button_pressed(wrapper->device, &pressed); } + + if (ctx->settings.mode == LVGL_TRACKBALL_MODE_KEYS) { + read_keys(indev, ctx, data, dx, dy, pressed); + return; + } + + int32_t max_x = display != nullptr ? lv_display_get_original_horizontal_resolution(display) - 1 : 0; + int32_t max_y = display != nullptr ? lv_display_get_original_vertical_resolution(display) - 1 : 0; + ctx->cursor_x = clamp(ctx->cursor_x + dx * static_cast(ctx->settings.pointer_sensitivity), 0, max_x); + ctx->cursor_y = clamp(ctx->cursor_y + dy * static_cast(ctx->settings.pointer_sensitivity), 0, max_y); + data->point.x = static_cast(ctx->cursor_x); + data->point.y = static_cast(ctx->cursor_y); data->state = pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; if (pressed) { @@ -113,9 +162,9 @@ extern "C" { LvglTrackballSettings lvgl_trackball_settings_get_default() { return LvglTrackballSettings { - .mode = LVGL_TRACKBALL_MODE_ENCODER, + .mode = LVGL_TRACKBALL_MODE_KEYS, .enabled = true, - .encoder_sensitivity = 1, + .key_sensitivity = 1, .pointer_sensitivity = 10, }; } @@ -147,7 +196,7 @@ error_t lvgl_trackball_add(struct Device* device, lv_display_t* display, lv_inde return ERROR_OUT_OF_MEMORY; } - lv_indev_set_type(indev, LV_INDEV_TYPE_ENCODER); + lv_indev_set_type(indev, LV_INDEV_TYPE_KEYPAD); lv_indev_set_read_cb(indev, lvgl_trackball_read_cb); lv_indev_set_driver_data(indev, wrapper); if (display != nullptr) { @@ -155,10 +204,10 @@ error_t lvgl_trackball_add(struct Device* device, lv_display_t* display, lv_inde } recenter_cursor(ctx, indev); - // Encoder indevs are useless without a group (LVGL dispatch bails out immediately if indev->group is NULL) + // Keypad indevs are useless without a group (LVGL dispatch bails out immediately if indev->group is NULL) // Use the keyboard group for now, until it's refactored into a proper global/shared group. See ideas.md // When refactoring this, you probably want to remove the calls to lvgl_keyboard_* below. - if (ctx->settings.mode == LVGL_TRACKBALL_MODE_ENCODER) { + if (ctx->settings.mode == LVGL_TRACKBALL_MODE_KEYS) { lvgl_keyboard_enable(indev); } @@ -188,8 +237,8 @@ error_t lvgl_trackball_set_settings(lv_indev_t* indev, const struct LvglTrackbal } if ( - (settings->mode != LVGL_TRACKBALL_MODE_ENCODER && settings->mode != LVGL_TRACKBALL_MODE_POINTER) || - settings->encoder_sensitivity == 0 || + (settings->mode != LVGL_TRACKBALL_MODE_KEYS && settings->mode != LVGL_TRACKBALL_MODE_POINTER) || + settings->key_sensitivity == 0 || settings->pointer_sensitivity == 0 ) { return ERROR_INVALID_ARGUMENT; @@ -203,6 +252,8 @@ error_t lvgl_trackball_set_settings(lv_indev_t* indev, const struct LvglTrackbal ctx->settings = *settings; if (mode_changed) { + ctx->pending_count = 0; + ctx->pressed_key = 0; if (settings->mode == LVGL_TRACKBALL_MODE_POINTER) { lvgl_keyboard_disable(indev); lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER); @@ -210,12 +261,12 @@ error_t lvgl_trackball_set_settings(lv_indev_t* indev, const struct LvglTrackbal show_cursor(ctx, indev); } else { hide_cursor(ctx); - lv_indev_set_type(indev, LV_INDEV_TYPE_ENCODER); + lv_indev_set_type(indev, LV_INDEV_TYPE_KEYPAD); lvgl_keyboard_enable(indev); } } - // Cursor visibility only tracks the enabled toggle in pointer mode - in encoder mode it must + // Cursor visibility only tracks the enabled toggle in pointer mode - in keys mode it must // stay hidden regardless of enabled, otherwise this unconditionally un-hides the cursor // hide_cursor() just hid above (enabled is independent of mode, and defaults to true). if (ctx->cursor != nullptr && ctx->settings.mode == LVGL_TRACKBALL_MODE_POINTER) { diff --git a/Modules/lvgl-module/source/grid_navigation.cpp b/Modules/lvgl-module/source/grid_navigation.cpp index 59d86afe0..069792a6c 100644 --- a/Modules/lvgl-module/source/grid_navigation.cpp +++ b/Modules/lvgl-module/source/grid_navigation.cpp @@ -65,27 +65,47 @@ void lvgl_grid_navigation_remove(lv_obj_t* container) { // Matches which children grid navigation can focus static bool is_focusable(lv_obj_t* obj) { - return !lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN) && lv_obj_has_flag_any(obj, static_cast(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_CLICK_FOCUSABLE)); + return !lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN) && lv_obj_has_flag(obj, static_cast(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_CLICK_FOCUSABLE)); } -void lvgl_grid_navigation_focus_next(lv_obj_t* container, bool next) { +// Finds the next focusable child after (or before) the given index +static lv_obj_t* find_focusable_child(lv_obj_t* container, int32_t index, bool next) { const auto child_count = static_cast(lv_obj_get_child_count(container)); - lv_obj_t* focused = lvgl_grid_navigation_get_focused(container); - int32_t index = focused != nullptr ? lv_obj_get_index(focused) : (next ? -1 : child_count); for (index += next ? 1 : -1; index >= 0 && index < child_count; index += next ? 1 : -1) { auto* child = lv_obj_get_child(container, index); if (is_focusable(child)) { - lv_gridnav_set_focused(container, child, LV_ANIM_ON); + return child; + } + } + return nullptr; +} + +void lvgl_grid_navigation_step(lv_group_t* group, bool next) { + lv_obj_t* focused = lv_group_get_focused(group); + if (lvgl_grid_navigation_is_container(focused)) { + lv_obj_t* focused_child = lvgl_grid_navigation_get_focused(focused); + const int32_t index = focused_child != nullptr ? lv_obj_get_index(focused_child) : (next ? -1 : static_cast(lv_obj_get_child_count(focused))); + lv_obj_t* child = find_focusable_child(focused, index, next); + if (child != nullptr) { + lv_gridnav_set_focused(focused, child, LV_ANIM_ON); return; } } - lv_group_t* group = lv_obj_get_group(container); - if (group != nullptr) { - if (next) { - lv_group_focus_next(group); - } else { - lv_group_focus_prev(group); + lv_group_set_editing(group, false); + if (next) { + lv_group_focus_next(group); + } else { + lv_group_focus_prev(group); + } + + // Grid navigation restores the child that was focused last, but stepping enters at the side it came from + lv_obj_t* entered = lv_group_get_focused(group); + if (lvgl_grid_navigation_is_container(entered)) { + const int32_t start = next ? -1 : static_cast(lv_obj_get_child_count(entered)); + lv_obj_t* child = find_focusable_child(entered, start, next); + if (child != nullptr) { + lv_gridnav_set_focused(entered, child, LV_ANIM_ON); } } } diff --git a/Modules/lvgl-module/source/icon_names.c b/Modules/lvgl-module/source/icon_names.c index ddc878bb7..34b143de0 100644 --- a/Modules/lvgl-module/source/icon_names.c +++ b/Modules/lvgl-module/source/icon_names.c @@ -58,6 +58,7 @@ const size_t lvgl_icon_shared_name_count = sizeof(lvgl_icon_shared_names) / size const char* const lvgl_icon_statusbar_names[] = { "location_on", + "location_off", "cloud", "memory", "sd_card", diff --git a/Modules/lvgl-module/source/insets.cpp b/Modules/lvgl-module/source/insets.cpp new file mode 100644 index 000000000..f1fe6f5a6 --- /dev/null +++ b/Modules/lvgl-module/source/insets.cpp @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include + +#include +#include + +static DisplayShape get_shape(lv_display_t* display) { + DisplayShape shape = { .shape = DISPLAY_SHAPE_RECTANGLE, .corner_radius = 0 }; + const auto* wrapper = static_cast(lv_display_get_driver_data(display)); + if (wrapper != nullptr && wrapper->device != nullptr) { + display_get_shape(wrapper->device, &shape); + } + return shape; +} + +struct EdgePadding { + lv_obj_t* obj; + // The padding before the display's edge was added + int32_t base_left; + int32_t base_right; +}; + +static void update_edge_padding(void* data) { + const auto* padding = static_cast(data); + lv_display_t* display = lv_obj_get_display(padding->obj); + lv_area_t coords; + lv_obj_get_coords(padding->obj, &coords); + // The curve is widest at the object's edge that is nearest to the display's top or bottom + const int32_t inset = std::max(lvgl_display_get_row_inset(display, coords.y1), lvgl_display_get_row_inset(display, coords.y2)); + const int32_t right_edge = lv_display_get_horizontal_resolution(display) - 1; + lv_obj_set_style_pad_left(padding->obj, padding->base_left + std::max(0, inset - coords.x1), LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(padding->obj, padding->base_right + std::max(0, inset - (right_edge - coords.x2)), LV_STATE_DEFAULT); +} + +// The position is final once the layout pass that changed the size has finished +static void on_size_changed(lv_event_t* event) { + lv_async_call(update_edge_padding, lv_event_get_user_data(event)); +} + +static void on_deleted(lv_event_t* event) { + auto* padding = static_cast(lv_event_get_user_data(event)); + lv_async_call_cancel(update_edge_padding, padding); + delete padding; +} + +extern "C" { + +void lvgl_obj_add_edge_padding(lv_obj_t* obj) { + if (get_shape(lv_obj_get_display(obj)).shape == DISPLAY_SHAPE_RECTANGLE) { + return; + } + auto* padding = new(std::nothrow) EdgePadding { + .obj = obj, + .base_left = lv_obj_get_style_pad_left(obj, LV_PART_MAIN), + .base_right = lv_obj_get_style_pad_right(obj, LV_PART_MAIN) + }; + if (padding == nullptr) { + return; + } + lv_obj_add_event_cb(obj, on_size_changed, LV_EVENT_SIZE_CHANGED, padding); + lv_obj_add_event_cb(obj, on_deleted, LV_EVENT_DELETE, padding); +} + +void lvgl_display_get_shape(lv_display_t* display, DisplayShape* out_shape) { + *out_shape = get_shape(display); +} + +void lvgl_display_get_insets(lv_display_t* display, LvglInsets* out_insets) { + *out_insets = lvgl_insets_calculate(get_shape(display), lv_display_get_horizontal_resolution(display), lv_display_get_vertical_resolution(display)); +} + +int32_t lvgl_display_get_row_inset(lv_display_t* display, int32_t y) { + return lvgl_insets_calculate_row(get_shape(display), lv_display_get_horizontal_resolution(display), lv_display_get_vertical_resolution(display), y); +} + +} diff --git a/Modules/lvgl-module/source/insets_math.cpp b/Modules/lvgl-module/source/insets_math.cpp new file mode 100644 index 000000000..a72092e99 --- /dev/null +++ b/Modules/lvgl-module/source/insets_math.cpp @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include + +// The part of a corner's square that a circle with the given radius cuts off along its diagonal +static int32_t corner_inset(int32_t radius) { + return static_cast(std::ceil(radius * (1.0 - M_SQRT1_2))); +} + +// The horizontal distance between a circle's edge and its bounding box at the given distance from the circle's center row +static int32_t circle_row_inset(int32_t radius, int32_t distance_from_center) { + if (distance_from_center >= radius) { + return radius; + } + const double half_width = std::sqrt(static_cast(radius) * radius - static_cast(distance_from_center) * distance_from_center); + return static_cast(std::ceil(radius - half_width)); +} + +LvglInsets lvgl_insets_calculate(const DisplayShape& shape, int32_t width, int32_t height) { + switch (shape.shape) { + case DISPLAY_SHAPE_ROUNDED: { + const int32_t radius = std::min(shape.corner_radius, std::min(width, height) / 2); + const int32_t inset = corner_inset(radius); + return { .top = inset, .bottom = inset, .left = 0, .right = 0 }; + } + case DISPLAY_SHAPE_CIRCLE: { + const int32_t diameter = std::min(width, height); + const int32_t inset = corner_inset(diameter / 2); + const int32_t horizontal = inset + (width - diameter) / 2; + const int32_t vertical = inset + (height - diameter) / 2; + return { .top = vertical, .bottom = vertical, .left = horizontal, .right = horizontal }; + } + default: + return { .top = 0, .bottom = 0, .left = 0, .right = 0 }; + } +} + +int32_t lvgl_insets_calculate_row(const DisplayShape& shape, int32_t width, int32_t height, int32_t y) { + switch (shape.shape) { + case DISPLAY_SHAPE_ROUNDED: { + const int32_t radius = std::min(shape.corner_radius, std::min(width, height) / 2); + // Distance from the row to the center row of the nearest corner's circle + const int32_t distance = std::max(radius - y, y - (height - 1 - radius)); + return distance > 0 ? circle_row_inset(radius, distance) : 0; + } + case DISPLAY_SHAPE_CIRCLE: { + const int32_t diameter = std::min(width, height); + const int32_t radius = diameter / 2; + const int32_t distance = std::abs(y - height / 2); + return std::min(circle_row_inset(radius, distance) + (width - diameter) / 2, width / 2); + } + default: + return 0; + } +} diff --git a/Modules/lvgl-module/source/symbols.c b/Modules/lvgl-module/source/symbols.c index 8edb9d34e..acf26fb70 100644 --- a/Modules/lvgl-module/source/symbols.c +++ b/Modules/lvgl-module/source/symbols.c @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -65,9 +66,14 @@ const struct ModuleSymbol lvgl_module_symbols[] = { // lvgl_grid_navigation DEFINE_MODULE_SYMBOL(lvgl_grid_navigation_add), DEFINE_MODULE_SYMBOL(lvgl_grid_navigation_remove), - DEFINE_MODULE_SYMBOL(lvgl_grid_navigation_focus_next), + DEFINE_MODULE_SYMBOL(lvgl_grid_navigation_step), DEFINE_MODULE_SYMBOL(lvgl_grid_navigation_get_focused), DEFINE_MODULE_SYMBOL(lvgl_grid_navigation_is_container), + // lvgl_insets + DEFINE_MODULE_SYMBOL(lvgl_display_get_shape), + DEFINE_MODULE_SYMBOL(lvgl_display_get_insets), + DEFINE_MODULE_SYMBOL(lvgl_display_get_row_inset), + DEFINE_MODULE_SYMBOL(lvgl_obj_add_edge_padding), // lvgl_theme DEFINE_MODULE_SYMBOL(lvgl_theme_get_settings), DEFINE_MODULE_SYMBOL(lvgl_theme_get_default_settings), diff --git a/Modules/lvgl-module/source/themes/lv_theme_material.c b/Modules/lvgl-module/source/themes/lv_theme_material.c index a5f5f5841..43c02b1dd 100644 --- a/Modules/lvgl-module/source/themes/lv_theme_material.c +++ b/Modules/lvgl-module/source/themes/lv_theme_material.c @@ -137,7 +137,7 @@ extern const lv_obj_class_t lvgl_badge_class; #define CHIP_SHAPE (theme->config.is_compact ? CORNER_COMPACT : CORNER_SMALL) #define CHIP_PAD_HOR (theme->config.is_compact ? 3 : SPACE_4) #define CHIP_PAD_VER (theme->config.is_compact ? 2 : LV_DPX_CALC(theme->disp_dpi, 6)) -#define BADGE_COLOR COLOR_PRIMARY +#define BADGE_COLOR COLOR_SECONDARY #define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) #define SCROLLBAR_COLOR COLOR_OUTLINE #define SCROLLBAR_OPACITY (LV_OPA_40) @@ -161,7 +161,7 @@ extern const lv_obj_class_t lvgl_badge_class; #define TEXT_BUTTON_LABEL_COLOR COLOR_PRIMARY #define TEXT_BUTTON_PRESSED_CONTAINER_COLOR COLOR_PRIMARY #define TEXT_BUTTON_PAD_HOR (theme->config.is_compact ? 3 : SPACE_3) -#define ICON_BUTTON_COLOR COLOR_ON_SURFACE_VARIANT +#define ICON_BUTTON_COLOR COLOR_ON_SURFACE #define ICON_BUTTON_CONTAINER_SHAPE (theme->config.is_compact ? CORNER_COMPACT : CORNER_LARGE) #define ICON_BUTTON_FILLED_COLOR COLOR_PRIMARY #define ICON_BUTTON_FILLED_ICON_COLOR COLOR_ON_PRIMARY @@ -396,7 +396,7 @@ typedef struct { lv_style_t checkbox_box_selected; #endif lv_style_t sliderbox; -#if LV_USE_SLIDER +#if LV_USE_BAR || LV_USE_SLIDER lv_style_t slider_track_size; #endif #if LV_USE_SLIDER @@ -874,7 +874,7 @@ static void style_init(my_theme_t * theme) lv_style_set_bg_opa(&theme->styles.app_bar_button_pressed, APP_BAR_PRESSED_OPACITY); style_init_reset(&theme->styles.app_bar_focus_ring); - lv_style_set_outline_color(&theme->styles.app_bar_focus_ring, APP_BAR_CONTENT_COLOR); + lv_style_set_outline_color(&theme->styles.app_bar_focus_ring, FOCUS_RING_COLOR); lv_style_set_outline_width(&theme->styles.app_bar_focus_ring, APP_BAR_FOCUS_WIDTH); lv_style_set_outline_pad(&theme->styles.app_bar_focus_ring, -APP_BAR_FOCUS_WIDTH); lv_style_set_outline_opa(&theme->styles.app_bar_focus_ring, LV_OPA_COVER); @@ -957,7 +957,7 @@ static void style_init(my_theme_t * theme) style_init_reset(&theme->styles.sliderbox); lv_style_set_pad_column(&theme->styles.sliderbox, SLIDERBOX_GAP); -#if LV_USE_SLIDER +#if LV_USE_BAR || LV_USE_SLIDER style_init_reset(&theme->styles.slider_track_size); lv_style_set_height(&theme->styles.slider_track_size, SLIDER_TRACK_HEIGHT); #endif @@ -1838,6 +1838,13 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj) lv_obj_add_style(obj, &theme->styles.badge, 0); return; } +#if LV_USE_IMAGE + if(lv_obj_check_type(obj, &lv_image_class)) { + /* image */ + lv_obj_add_style(obj, &theme->styles.focus_ring_inset, LV_STATE_FOCUS_KEY); + return; + } +#endif if(lv_obj_check_type(obj, &lvgl_chip_class)) { /* chip */ lv_obj_add_style(obj, &theme->styles.chip, 0); @@ -1878,6 +1885,7 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj) #if LV_USE_BAR if(lv_obj_check_type(obj, &lv_bar_class)) { /* bar */ + lv_obj_add_style(obj, &theme->styles.slider_track_size, 0); lv_obj_add_style(obj, &theme->styles.progress_track, 0); lv_obj_add_style(obj, &theme->styles.progress_indicator, LV_PART_INDICATOR); lv_obj_add_style(obj, &theme->styles.focus_ring, LV_STATE_FOCUS_KEY); 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 143c46c4d..cc35164b0 100644 --- a/Modules/lvgl-module/source/themes/lv_theme_material_mono.c +++ b/Modules/lvgl-module/source/themes/lv_theme_material_mono.c @@ -250,7 +250,7 @@ typedef struct { lv_style_t checkbox_box_selected; #endif lv_style_t sliderbox; -#if LV_USE_SLIDER +#if LV_USE_BAR || LV_USE_SLIDER lv_style_t slider_track_size; #endif #if LV_USE_SLIDER @@ -688,7 +688,7 @@ static void style_init(my_theme_t * theme) style_init_reset(&theme->styles.sliderbox); lv_style_set_pad_column(&theme->styles.sliderbox, SLIDERBOX_GAP); -#if LV_USE_SLIDER +#if LV_USE_BAR || LV_USE_SLIDER style_init_reset(&theme->styles.slider_track_size); lv_style_set_height(&theme->styles.slider_track_size, SLIDER_TRACK_HEIGHT); #endif @@ -1470,6 +1470,7 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj) #if LV_USE_BAR if(lv_obj_check_type(obj, &lv_bar_class)) { /* bar */ + lv_obj_add_style(obj, &theme->styles.slider_track_size, 0); lv_obj_add_style(obj, &theme->styles.slider_track, 0); lv_obj_add_style(obj, &theme->styles.slider_active_track, LV_PART_INDICATOR); lv_obj_add_style(obj, &theme->styles.focus_ring, LV_STATE_FOCUS_KEY); diff --git a/Modules/lvgl-module/source/widgets/toolbar.cpp b/Modules/lvgl-module/source/widgets/toolbar.cpp index 9d31934c6..4c69bd7b1 100644 --- a/Modules/lvgl-module/source/widgets/toolbar.cpp +++ b/Modules/lvgl-module/source/widgets/toolbar.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -100,6 +101,9 @@ lv_obj_t* lvgl_toolbar_create(lv_obj_t* parent, const char* title) { toolbar->nav_action_callback = nullptr; lv_obj_center(obj); + + lvgl_obj_add_edge_padding(obj); + lv_obj_set_flex_flow(obj, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(obj, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); // The arrow keys move between the close button and the actions, and up and down leave the toolbar diff --git a/Modules/lvgl-module/tests/CMakeLists.txt b/Modules/lvgl-module/tests/CMakeLists.txt new file mode 100644 index 000000000..961d96265 --- /dev/null +++ b/Modules/lvgl-module/tests/CMakeLists.txt @@ -0,0 +1,22 @@ +project(LvglModuleTests) + +enable_language(C CXX ASM) + +file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp) +add_executable(LvglModuleTests EXCLUDE_FROM_ALL + ${TEST_SOURCES} + ${PROJECT_SOURCE_DIR}/../source/insets_math.cpp +) + +target_include_directories(LvglModuleTests PRIVATE + ${DOCTESTINC} + ${PROJECT_SOURCE_DIR}/../include + ${PROJECT_SOURCE_DIR}/../private +) + +add_test(NAME LvglModuleTests COMMAND LvglModuleTests) + +target_link_libraries(LvglModuleTests PUBLIC + TactilityKernel + lvgl +) diff --git a/Modules/lvgl-module/tests/source/insets_math_test.cpp b/Modules/lvgl-module/tests/source/insets_math_test.cpp new file mode 100644 index 000000000..ff11b01bf --- /dev/null +++ b/Modules/lvgl-module/tests/source/insets_math_test.cpp @@ -0,0 +1,62 @@ +#include "doctest.h" + +#include + +static constexpr DisplayShape RECTANGLE = { .shape = DISPLAY_SHAPE_RECTANGLE, .corner_radius = 0 }; +static constexpr DisplayShape CIRCLE = { .shape = DISPLAY_SHAPE_CIRCLE, .corner_radius = 0 }; +static constexpr DisplayShape ROUNDED_40 = { .shape = DISPLAY_SHAPE_ROUNDED, .corner_radius = 40 }; + +TEST_CASE("a rectangle has no insets") { + const auto insets = lvgl_insets_calculate(RECTANGLE, 320, 240); + CHECK_EQ(insets.top, 0); + CHECK_EQ(insets.bottom, 0); + CHECK_EQ(insets.left, 0); + CHECK_EQ(insets.right, 0); + CHECK_EQ(lvgl_insets_calculate_row(RECTANGLE, 320, 240, 0), 0); +} + +TEST_CASE("a circle's insets fit the inscribed square") { + const auto insets = lvgl_insets_calculate(CIRCLE, 240, 240); + // 120 * (1 - 1/sqrt(2)) = 35.1 + CHECK_EQ(insets.top, 36); + CHECK_EQ(insets.bottom, 36); + CHECK_EQ(insets.left, 36); + CHECK_EQ(insets.right, 36); +} + +TEST_CASE("a circle on a wider panel is centered horizontally") { + const auto insets = lvgl_insets_calculate(CIRCLE, 280, 240); + CHECK_EQ(insets.top, 36); + CHECK_EQ(insets.left, 56); + CHECK_EQ(insets.right, 56); +} + +TEST_CASE("a circle's row inset follows its edge") { + CHECK_EQ(lvgl_insets_calculate_row(CIRCLE, 240, 240, 120), 0); + CHECK_EQ(lvgl_insets_calculate_row(CIRCLE, 240, 240, 0), 120); + // Halfway between the center and the top: 120 - sqrt(120^2 - 60^2) = 16.1 + CHECK_EQ(lvgl_insets_calculate_row(CIRCLE, 240, 240, 60), 17); +} + +TEST_CASE("a rounded rectangle has insets at the top and bottom only") { + const auto insets = lvgl_insets_calculate(ROUNDED_40, 172, 320); + // 40 * (1 - 1/sqrt(2)) = 11.7 + CHECK_EQ(insets.top, 12); + CHECK_EQ(insets.bottom, 12); + CHECK_EQ(insets.left, 0); + CHECK_EQ(insets.right, 0); +} + +TEST_CASE("a rounded rectangle's row inset is only in the corners") { + CHECK_EQ(lvgl_insets_calculate_row(ROUNDED_40, 172, 320, 0), 40); + CHECK_EQ(lvgl_insets_calculate_row(ROUNDED_40, 172, 320, 319), 40); + CHECK_EQ(lvgl_insets_calculate_row(ROUNDED_40, 172, 320, 40), 0); + CHECK_EQ(lvgl_insets_calculate_row(ROUNDED_40, 172, 320, 160), 0); +} + +TEST_CASE("a corner radius larger than half the panel is limited") { + const DisplayShape huge = { .shape = DISPLAY_SHAPE_ROUNDED, .corner_radius = 500 }; + const auto insets = lvgl_insets_calculate(huge, 172, 320); + // Limited to 86: 86 * (1 - 1/sqrt(2)) = 25.2 + CHECK_EQ(insets.top, 26); +} diff --git a/Modules/lvgl-module/tests/source/main.cpp b/Modules/lvgl-module/tests/source/main.cpp new file mode 100644 index 000000000..a3f832e49 --- /dev/null +++ b/Modules/lvgl-module/tests/source/main.cpp @@ -0,0 +1,2 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include "doctest.h" diff --git a/Modules/lvgl-module/themes/material.lss b/Modules/lvgl-module/themes/material.lss index e95ab34c3..2b06d98f6 100644 --- a/Modules/lvgl-module/themes/material.lss +++ b/Modules/lvgl-module/themes/material.lss @@ -200,7 +200,7 @@ @chip-pad-ver: if($isCompact, 2, 6dp); // Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite -@badge-color: @color-primary; +@badge-color: @color-secondary; @badge-size: if($isCompact, 4, 6dp); // Scrollbar @@ -232,7 +232,7 @@ @text-button-pad-hor: if($isCompact, 3, @space-3); // Icon button (dialog and window header buttons) -@icon-button-color: @color-on-surface-variant; +@icon-button-color: @color-on-surface; // Filled and tonal icon buttons: Material 3 large icon buttons with a container @icon-button-container-shape: if($isCompact, @corner-compact, @corner-large); @@ -687,7 +687,7 @@ app_bar_button_pressed { } app_bar_focus_ring { - outlineColor: @app-bar-content-color; + outlineColor: @focus-ring-color; outlineWidth: @app-bar-focus-width; outlinePad: -@app-bar-focus-width; outlineOpa: cover; @@ -1378,6 +1378,11 @@ apply(badge) { badge: default; } +// Focusable images, e.g. the statusbar icons +apply(image) { + focus_ring_inset: state.focusKey; +} + apply(chip) { chip: default; transition_exit: default; @@ -1410,6 +1415,7 @@ apply(slider) { } apply(bar) { + slider_track_size: default; progress_track: default; progress_indicator: part.indicator; focus_ring: state.focusKey; diff --git a/Modules/lvgl-module/themes/material_mono.lss b/Modules/lvgl-module/themes/material_mono.lss index 966f45b0d..6ae20b1f8 100644 --- a/Modules/lvgl-module/themes/material_mono.lss +++ b/Modules/lvgl-module/themes/material_mono.lss @@ -1070,6 +1070,7 @@ apply(slider) { } apply(bar) { + slider_track_size: default; slider_track: default; slider_active_track: part.indicator; focus_ring: state.focusKey; diff --git a/Tactility/Include/Tactility/lvgl/Statusbar.h b/Tactility/Include/Tactility/lvgl/Statusbar.h index 302f28646..59f53b5cd 100644 --- a/Tactility/Include/Tactility/lvgl/Statusbar.h +++ b/Tactility/Include/Tactility/lvgl/Statusbar.h @@ -26,6 +26,13 @@ void statusbar_icon_set_image(int8_t id, const std::string& image); /** Update the visibility for an icon on the statusbar. Does not need to be called with LVGL lock. */ void statusbar_icon_set_visibility(int8_t id, bool visible); +/** + * Sets the app that starts when the icon is clicked. Does not need to be called with LVGL lock. + * @param[in] id the icon id + * @param[in] appId the app id, or an empty string to make the icon not clickable + */ +void statusbar_icon_set_app(int8_t id, const std::string& appId); + int statusbar_get_height(); } // namespace diff --git a/Tactility/Private/Tactility/app/AppGrid.h b/Tactility/Private/Tactility/app/AppGrid.h index 5fc0ed736..f1fb80d0a 100644 --- a/Tactility/Private/Tactility/app/AppGrid.h +++ b/Tactility/Private/Tactility/app/AppGrid.h @@ -19,7 +19,7 @@ struct AppGridItem { }; /** - * Paged, non-scrolling grid of app tiles (icon above name) with "<" and ">" toolbar buttons. + * Paged, non-scrolling grid of app tiles (icon above name) with "<" and ">" page buttons. * Must outlive the widgets it creates. */ class AppGrid final { @@ -39,9 +39,19 @@ public: explicit AppGrid(const Callbacks& callbacks) : callbacks(callbacks) {} + /** Shows the tile icons in the theme's primary color. Call before creating the widgets. */ + void setPrimaryColorIcons(bool enabled) { primaryColorIcons = enabled; } + /** Adds the paging buttons to the toolbar, creates the grid below it and populates it. */ void createWidgets(lv_obj_t* parent, lv_obj_t* toolbar); + /** + * Creates the grid and populates it. The page number and the page buttons go in pageBar, which is hidden when there's only one page. The page buttons wrap around. + * @param[in] parent the container for the grid + * @param[in] pageBar an empty container + */ + void createWidgetsWithPageBar(lv_obj_t* parent, lv_obj_t* pageBar); + /** * Rebuilds the grid asynchronously, so it is safe to call from a tile's own event. * @param[in] keepSelection when true, the selected app stays selected and its page is shown @@ -53,11 +63,16 @@ private: Callbacks callbacks; std::vector items; uint32_t page = 0; + uint32_t pageCount = 1; + bool primaryColorIcons = false; bool keepSelectionOnRepopulate = true; lv_obj_t* grid = nullptr; lv_obj_t* prevButton = nullptr; lv_obj_t* nextButton = nullptr; + lv_obj_t* pageBar = nullptr; + lv_obj_t* pageLabel = nullptr; + void createGrid(lv_obj_t* parent); void populate(); static void onDeferredRepopulate(void* userData); diff --git a/Tactility/Private/Tactility/app/applist/Favourites.h b/Tactility/Private/Tactility/app/launcher/Favourites.h similarity index 94% rename from Tactility/Private/Tactility/app/applist/Favourites.h rename to Tactility/Private/Tactility/app/launcher/Favourites.h index c029b7983..1bf364884 100644 --- a/Tactility/Private/Tactility/app/applist/Favourites.h +++ b/Tactility/Private/Tactility/app/launcher/Favourites.h @@ -9,7 +9,7 @@ #include #include -namespace tt::app::applist { +namespace tt::app::launcher { /** Favourited app ids, persisted one per line under the data root. */ class Favourites final { @@ -81,7 +81,7 @@ private: static std::string filePath() { char path[128]; - if (app_paths_get_user_data_path("tactility.applist", "favourites", path, sizeof(path)) != ERROR_NONE) { + if (app_paths_get_user_data_path("tactility.launcher", "favourites", path, sizeof(path)) != ERROR_NONE) { return ""; } return path; diff --git a/Tactility/Source/InitApps.cpp b/Tactility/Source/InitApps.cpp index ec362a8b4..53c100bd7 100644 --- a/Tactility/Source/InitApps.cpp +++ b/Tactility/Source/InitApps.cpp @@ -42,7 +42,6 @@ namespace app { namespace appearancesettings { extern const ::AppManifest manifest; } namespace apphubdetails { extern const ::AppManifest manifest; } namespace apppackagedetails { extern const ::AppManifest manifest; } - namespace applist { extern const ::AppManifest manifest; } namespace apppackagelist { extern const ::AppManifest manifest; } namespace audiosettings { extern const ::AppManifest manifest; } namespace boot { extern const ::AppManifest manifest; } @@ -110,7 +109,6 @@ static void registerInternalApps() { app_manager_add(&app::apppackagedetails::manifest); app_manager_add(&app::apphub::manifest); app_manager_add(&app::apphubdetails::manifest); - app_manager_add(&app::applist::manifest); app_manager_add(&app::apppackagelist::manifest); if (service::audio::isAvailable()) { app_manager_add(&app::audiosettings::manifest); diff --git a/Tactility/Source/app/AppGrid.cpp b/Tactility/Source/app/AppGrid.cpp index bbe303a69..3813a9ab4 100644 --- a/Tactility/Source/app/AppGrid.cpp +++ b/Tactility/Source/app/AppGrid.cpp @@ -74,13 +74,23 @@ void AppGrid::onPrevPressed(lv_event_t* e) { auto* self = static_cast(lv_event_get_user_data(e)); if (self->page > 0) { self->page--; - self->populate(); + } else if (self->pageBar != nullptr) { + self->page = self->pageCount - 1; + } else { + return; } + self->populate(); } void AppGrid::onNextPressed(lv_event_t* e) { auto* self = static_cast(lv_event_get_user_data(e)); - self->page++; + if (self->page + 1 < self->pageCount) { + self->page++; + } else if (self->pageBar != nullptr) { + self->page = 0; + } else { + return; + } self->populate(); } @@ -140,11 +150,17 @@ void AppGrid::populate() { } } page = std::min(page, page_count - 1); + pageCount = page_count; - lv_obj_set_state(prevButton, LV_STATE_DISABLED, page == 0); - lv_obj_set_state(nextButton, LV_STATE_DISABLED, page + 1 >= page_count); - lv_obj_set_flag(prevButton, LV_OBJ_FLAG_HIDDEN, page_count <= 1); - lv_obj_set_flag(nextButton, LV_OBJ_FLAG_HIDDEN, page_count <= 1); + if (pageBar != nullptr) { + lv_obj_set_flag(pageBar, LV_OBJ_FLAG_HIDDEN, page_count <= 1); + lv_label_set_text_fmt(pageLabel, "%lu/%lu", static_cast(page + 1), static_cast(page_count)); + } else { + lv_obj_set_state(prevButton, LV_STATE_DISABLED, page == 0); + lv_obj_set_state(nextButton, LV_STATE_DISABLED, page + 1 >= page_count); + lv_obj_set_flag(prevButton, LV_OBJ_FLAG_HIDDEN, page_count <= 1); + lv_obj_set_flag(nextButton, LV_OBJ_FLAG_HIDDEN, page_count <= 1); + } lv_obj_t* focusedTile = nullptr; const uint32_t first = page * layout.pageSize; @@ -166,6 +182,9 @@ void AppGrid::populate() { lv_obj_set_style_text_align(icon, LV_TEXT_ALIGN_CENTER, LV_STATE_DEFAULT); lv_obj_set_size(icon, layout.iconSize, layout.iconSize); lv_label_set_text(icon, item.icon); + if (primaryColorIcons) { + lv_obj_set_style_text_color(icon, lv_theme_get_color_primary(icon), LV_STATE_DEFAULT); + } lv_obj_t* label = lv_label_create(tile); lv_obj_set_style_text_font(label, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT); @@ -200,10 +219,7 @@ void AppGrid::populate() { } } -void AppGrid::createWidgets(lv_obj_t* parent, lv_obj_t* toolbar) { - prevButton = lvgl_toolbar_add_text_button_action(toolbar, "<", onPrevPressed, this); - nextButton = lvgl_toolbar_add_text_button_action(toolbar, ">", onNextPressed, this); - +void AppGrid::createGrid(lv_obj_t* parent) { grid = lv_obj_create(parent); lv_obj_set_width(grid, LV_PCT(100)); lv_obj_set_flex_grow(grid, 1); @@ -215,11 +231,17 @@ void AppGrid::createWidgets(lv_obj_t* parent, lv_obj_t* toolbar) { lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE); // The arrow keys move between the tiles in rows and columns lvgl_grid_navigation_add(grid); + lv_obj_add_event_cb(grid, onGridDeleted, LV_EVENT_DELETE, this); +} + +void AppGrid::createWidgets(lv_obj_t* parent, lv_obj_t* toolbar) { + prevButton = lvgl_toolbar_add_text_button_action(toolbar, "<", onPrevPressed, this); + nextButton = lvgl_toolbar_add_text_button_action(toolbar, ">", onNextPressed, this); + + createGrid(parent); // Resolves the grid's flex_grow height, which the page size is derived from. lv_obj_update_layout(parent); - - lv_obj_add_event_cb(grid, onGridDeleted, LV_EVENT_DELETE, this); lv_obj_add_event_cb(grid, onGridSizeChanged, LV_EVENT_SIZE_CHANGED, this); populate(); @@ -231,4 +253,32 @@ void AppGrid::createWidgets(lv_obj_t* parent, lv_obj_t* toolbar) { } } +void AppGrid::createWidgetsWithPageBar(lv_obj_t* parent, lv_obj_t* pageBar) { + createGrid(parent); + + this->pageBar = pageBar; + lv_obj_set_flex_flow(pageBar, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(pageBar, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + pageLabel = lv_label_create(pageBar); + prevButton = lvgl_icon_button_create(pageBar); + lv_label_set_text(lv_label_create(prevButton), "<"); + lv_obj_add_event_cb(prevButton, onPrevPressed, LV_EVENT_SHORT_CLICKED, this); + nextButton = lvgl_icon_button_create(pageBar); + lv_label_set_text(lv_label_create(nextButton), ">"); + lv_obj_add_event_cb(nextButton, onNextPressed, LV_EVENT_SHORT_CLICKED, this); + // The arrow keys move between the page buttons + lvgl_grid_navigation_add(pageBar); + + // Resolves the grid's flex_grow height, which the page size is derived from. + lv_obj_update_layout(parent); + lv_obj_add_event_cb(grid, onGridSizeChanged, LV_EVENT_SIZE_CHANGED, this); + + populate(); + + if (!lvgl_indev_exists(LV_INDEV_TYPE_POINTER) && lv_obj_get_child_count(grid) > 0) { + lv_group_focus_obj(grid); + lv_gridnav_set_focused(grid, lv_obj_get_child(grid, 0), LV_ANIM_OFF); + } +} + } diff --git a/Tactility/Source/app/appearancesettings/AppearanceSettings.cpp b/Tactility/Source/app/appearancesettings/AppearanceSettings.cpp index f5565f11a..d1513ecfd 100644 --- a/Tactility/Source/app/appearancesettings/AppearanceSettings.cpp +++ b/Tactility/Source/app/appearancesettings/AppearanceSettings.cpp @@ -523,10 +523,18 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_spinbox_set_range(font_size_spinbox, MIN_FONT_SIZE, MAX_FONT_SIZE); lv_spinbox_set_digit_format(font_size_spinbox, 2, 0); lv_spinbox_set_step(font_size_spinbox, 1); + // Wide enough for the digits and the room that the text field keeps for its cursor, so it never scrolls the digits out of view. + // The size is set before the value, because setting the value scrolls to the cursor using the current size. + const lv_font_t* font_size_font = lv_obj_get_style_text_font(font_size_spinbox, LV_PART_MAIN); + lv_point_t digits_size; + lv_text_get_size(&digits_size, "00", font_size_font, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + const int32_t font_size_content_width = digits_size.x + lv_font_get_line_height(font_size_font); + lv_obj_set_width(font_size_spinbox, font_size_content_width + + lv_obj_get_style_pad_left(font_size_spinbox, LV_PART_MAIN) + lv_obj_get_style_pad_right(font_size_spinbox, LV_PART_MAIN) + + 2 * lv_obj_get_style_border_width(font_size_spinbox, LV_PART_MAIN)); + lv_obj_set_style_text_align(font_size_spinbox, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); const uint16_t font_size = ctx->pendingSettings.fontSize != 0 ? ctx->pendingSettings.fontSize : static_cast(TT_FONT_DEFAULT_SIZE); lv_spinbox_set_value(font_size_spinbox, font_size); - lv_obj_set_width(font_size_spinbox, lv_font_get_line_height(lv_obj_get_style_text_font(font_size_spinbox, LV_PART_MAIN)) * 3); - lv_obj_set_style_text_align(font_size_spinbox, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); // The value is changed with the buttons, so the digit cursor isn't shown lv_obj_set_style_bg_opa(font_size_spinbox, LV_OPA_TRANSP, LV_PART_CURSOR); lv_obj_add_event_cb(font_size_spinbox, onFontSizeChanged, LV_EVENT_VALUE_CHANGED, ctx); diff --git a/Tactility/Source/app/applist/AppList.cpp b/Tactility/Source/app/applist/AppList.cpp deleted file mode 100644 index 007097b4b..000000000 --- a/Tactility/Source/app/applist/AppList.cpp +++ /dev/null @@ -1,219 +0,0 @@ -#include -#include -#include -#include -#include -#include - -#include - -#include -#include -#include - -#include -#include - -#include -#include -#include -#include - -#include -#include -#include - -namespace tt::app::applist { - -constexpr auto* TAG = "AppList"; - -namespace { - -struct IconEntry { - const char* id; - const char* icon; -}; - -constexpr IconEntry ICONS[] = { - {"tactility.apphub", LVGL_ICON_SHARED_DOWNLOAD}, - {"tactility.camera", LVGL_ICON_SHARED_CAMERA}, - {"tactility.chat", LVGL_ICON_SHARED_FORUM}, - {"tactility.i2cscanner", LVGL_ICON_SHARED_CABLE}, - {"tactility.notes", LVGL_ICON_SHARED_EDIT_NOTE}, - {"tactility.screenshot", LVGL_ICON_SHARED_IMAGE}, - {"tactility.systeminfo", LVGL_ICON_SHARED_DEVICES}, - {"tactility.terminal", LVGL_ICON_SHARED_TERMINAL}, - {"tactility.webserversettings", LVGL_ICON_SHARED_CLOUD}, -}; - -const char* appIcon(const ::AppManifest* manifest) { - for (const auto& entry : ICONS) { - if (!strcmp(manifest->id, entry.id)) return entry.icon; - } - return LVGL_ICON_SHARED_DEPLOYED_CODE; -} - -void collectManifest(const ::AppManifest* manifest, void* context) { - auto* manifests = static_cast*>(context); - manifests->push_back(*manifest); -} - -// Apps from installed packages this device can't run (wrong device, too little RAM) -void collectIncompatibleAppIds(const AppPackage* package, void* context) { - if (app_package_manifest_is_compatible(&package->package)) { - return; - } - auto* ids = static_cast*>(context); - for (size_t i = 0; i < package->app_id_count; i++) { - ids->emplace_back(package->app_ids[i]); - } -} - -std::vector collectItems(void* userData); -void onAppClicked(const ::AppManifest& manifest, void* userData); -void onAppLongPressed(const ::AppManifest& manifest, void* userData); -void onAppKey(const ::AppManifest& manifest, uint32_t key, void* userData); - -struct Context { - uint32_t appInstanceId; - Favourites favourites; - AppGrid grid { AppGrid::Callbacks { - .collect = collectItems, - .onClicked = onAppClicked, - .onLongPressed = onAppLongPressed, - .onKey = onAppKey, - .userData = this - } }; -}; - -std::vector collectItems(void* userData) { - const auto* ctx = static_cast(userData); - const std::vector favouriteIds = ctx->favourites.load(); - - std::vector<::AppManifest> collected; - app_manager_for_each_manifest(collectManifest, &collected); - std::vector incompatibleIds; - app_manager_for_each_package(collectIncompatibleAppIds, &incompatibleIds); - std::erase_if(collected, [&](const ::AppManifest& manifest) { - const bool is_valid_category = (manifest.category == APP_CATEGORY_USER) || (manifest.category == APP_CATEGORY_SYSTEM); - if (!is_valid_category || (manifest.flags & APP_MANIFEST_FLAG_HIDDEN) != 0) { - return true; - } - return std::ranges::find(incompatibleIds, std::string(manifest.id)) != incompatibleIds.end(); - }); - - std::vector items; - items.reserve(collected.size()); - for (const auto& manifest : collected) { - items.push_back({ manifest, appIcon(&manifest), Favourites::contains(favouriteIds, manifest.id) }); - } - std::ranges::sort(items, [](const AppGridItem& a, const AppGridItem& b) { - if (a.highlighted != b.highlighted) { - return a.highlighted; - } - return strcmp(a.manifest.name, b.manifest.name) < 0; - }); - return items; -} - -void onAppClicked(const ::AppManifest& manifest, void* userData) { - const auto* ctx = static_cast(userData); - uint32_t instanceId = 0; - AppStartContext context; - if (app_start_context_from_id(manifest.id, &context) == ERROR_NONE && app_start_with_context(&context, &instanceId) == ERROR_NONE) { - app_event_emit_close(ctx->appInstanceId); - } -} - -void toggleFavourite(Context* ctx, const char* appId, bool keepSelection) { - if (!ctx->favourites.toggle(appId)) { - LOG_E(TAG, "Failed to save favourites"); - } - ctx->grid.requestRepopulate(keepSelection); -} - -void onAppLongPressed(const ::AppManifest& manifest, void* userData) { - toggleFavourite(static_cast(userData), manifest.id, false); -} - -void onAppKey(const ::AppManifest& manifest, uint32_t key, void* userData) { - if (key == 'f' || key == 'F') { - toggleFavourite(static_cast(userData), manifest.id, true); - } -} - -void onBackPressed(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - app_event_emit_close(ctx->appInstanceId); -} - -void onHelpPressed(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - alertdialog::start(ctx->appInstanceId, "Help", "Long-press an app, or press F, to toggle it as a favorite."); -} - -void createWidgets(lv_obj_t* parent, void* userData) { - auto* ctx = static_cast(userData); - - // Flex column + flex_grow so the toolbar/grid split recomputes on layout instead of going stale after a resize. - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - - auto* toolbar = lvgl_toolbar_create(parent, "Apps"); - lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); - ctx->grid.createWidgets(parent, toolbar); - auto* help_button = lvgl_toolbar_add_text_button_action(toolbar, LVGL_ICON_SHARED_HELP, onHelpPressed, ctx); - lv_obj_set_style_text_font(help_button, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); -} - -int32_t appMain(int argc, char* argv[]) { - uint32_t appInstanceId = app_scheduler_current_app_id(); - Context ctx { appInstanceId }; - - TaskEventGroup event_group {}; - task_event_group_construct(&event_group); - - AppEventSubscription sub {}; - check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); - - WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - - while (true) { - task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY); - - bool shouldClose = false; - AppEvent event {}; - while (app_event_poll(&sub, &event) == ERROR_NONE) { - switch (event.type) { - case APP_EVENT_CLOSE: - shouldClose = true; - break; - case APP_EVENT_RESULT: - app_manager_stop(event.result.launch_id); - break; - default: - break; - } - if (shouldClose) break; - } - if (shouldClose) break; - } - - window_manager_remove(window); - check(app_event_unsubscribe(&sub) == ERROR_NONE); - task_event_group_destruct(&event_group); - return 0; -} - -} // namespace - -extern const ::AppManifest manifest = { - .id = "tactility.applist", - .name = "Apps", - .category = APP_CATEGORY_SYSTEM, - .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(appMain) }, - .flags = APP_MANIFEST_FLAG_HIDDEN, - .stack = { .depth = 5120, .desired_memory_capability = 0 }, -}; - -} // namespace diff --git a/Tactility/Source/app/apppackagelist/AppPackageList.cpp b/Tactility/Source/app/apppackagelist/AppPackageList.cpp index 3a5d05a3f..b48d1eb0f 100644 --- a/Tactility/Source/app/apppackagelist/AppPackageList.cpp +++ b/Tactility/Source/app/apppackagelist/AppPackageList.cpp @@ -64,8 +64,8 @@ void collectPackage(const ::AppPackage* pkg, void* context) { void createWidgets(lv_obj_t* parent, void* userData) { auto* ctx = static_cast(userData); - // Flex column + flex_grow; see AppList.cpp's createWidgets() for why a fixed height computed - // once from lv_obj_get_content_height(parent) goes stale. + // Flex column + flex_grow, because a fixed height computed once from + // lv_obj_get_content_height(parent) goes stale after a resize. lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); diff --git a/Tactility/Source/app/audiosettings/AudioSettings.cpp b/Tactility/Source/app/audiosettings/AudioSettings.cpp index 82ada4079..bcc61daf6 100644 --- a/Tactility/Source/app/audiosettings/AudioSettings.cpp +++ b/Tactility/Source/app/audiosettings/AudioSettings.cpp @@ -27,10 +27,12 @@ struct Context { PubSub::SubscriptionHandle audioSubscription = nullptr; lv_obj_t* inputEnabledSwitch = nullptr; + lv_obj_t* inputCard = nullptr; lv_obj_t* inputMuteSwitch = nullptr; lv_obj_t* inputVolumeSlider = nullptr; lv_obj_t* outputEnabledSwitch = nullptr; + lv_obj_t* outputCard = nullptr; lv_obj_t* outputMuteSwitch = nullptr; lv_obj_t* outputVolumeSlider = nullptr; }; @@ -42,15 +44,19 @@ void onBackPressed(lv_event_t* event) { } void onInputEnabledSwitch(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); auto* sw = static_cast(lv_event_get_target(event)); bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED); service::audio::setInputEnabled(enabled); + lv_obj_set_flag(ctx->inputCard, LV_OBJ_FLAG_HIDDEN, !enabled); } void onOutputEnabledSwitch(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); auto* sw = static_cast(lv_event_get_target(event)); bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED); service::audio::setOutputEnabled(enabled); + lv_obj_set_flag(ctx->outputCard, LV_OBJ_FLAG_HIDDEN, !enabled); } void onInputMuteSwitch(lv_event_t* event) { @@ -77,17 +83,6 @@ void onOutputVolumeSlider(lv_event_t* event) { service::audio::setOutputVolume(percent); } -lv_obj_t* createSection(lv_obj_t* parent, const char* title) { - auto* title_label = lv_label_create(parent); - lv_label_set_text(title_label, title); - - auto* card = lvgl_card_create(parent); - lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); - - return card; -} - // An unstyled container, so the rows don't paint over the card lv_obj_t* createRow(lv_obj_t* parent) { auto* row = lv_obj_create(parent); @@ -96,6 +91,32 @@ lv_obj_t* createRow(lv_obj_t* parent) { return row; } +struct Section { + lv_obj_t* enabledSwitch; + lv_obj_t* card; +}; + +// The title has the device's enable switch on its right. The card with the device's settings is hidden while it's disabled. +Section createSection(lv_obj_t* parent, const char* title, bool enabled, lv_event_cb_t onEnabledChanged, void* userData) { + auto* header = createRow(parent); + + auto* title_label = lv_label_create(header); + lv_label_set_text(title_label, title); + lv_obj_align(title_label, LV_ALIGN_LEFT_MID, 0, 0); + + auto* sw = lv_switch_create(header); + lv_obj_align(sw, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_state(sw, LV_STATE_CHECKED, enabled); + lv_obj_add_event_cb(sw, onEnabledChanged, LV_EVENT_VALUE_CHANGED, userData); + + auto* card = lvgl_card_create(parent); + lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flag(card, LV_OBJ_FLAG_HIDDEN, !enabled); + + return { sw, card }; +} + lv_obj_t* createSwitchRow(lv_obj_t* parent, const char* label, lv_event_cb_t cb, void* userData) { auto* row = createRow(parent); @@ -127,8 +148,9 @@ lv_obj_t* createSliderRow(lv_obj_t* parent, const char* label, int32_t initialVa void refresh(Context* ctx) { if (ctx->inputEnabledSwitch) { - if (service::audio::isInputEnabled()) lv_obj_add_state(ctx->inputEnabledSwitch, LV_STATE_CHECKED); - else lv_obj_remove_state(ctx->inputEnabledSwitch, LV_STATE_CHECKED); + const bool enabled = service::audio::isInputEnabled(); + lv_obj_set_state(ctx->inputEnabledSwitch, LV_STATE_CHECKED, enabled); + lv_obj_set_flag(ctx->inputCard, LV_OBJ_FLAG_HIDDEN, !enabled); } if (ctx->inputMuteSwitch) { if (service::audio::isInputMuted()) lv_obj_add_state(ctx->inputMuteSwitch, LV_STATE_CHECKED); @@ -139,8 +161,9 @@ void refresh(Context* ctx) { } if (ctx->outputEnabledSwitch) { - if (service::audio::isOutputEnabled()) lv_obj_add_state(ctx->outputEnabledSwitch, LV_STATE_CHECKED); - else lv_obj_remove_state(ctx->outputEnabledSwitch, LV_STATE_CHECKED); + const bool enabled = service::audio::isOutputEnabled(); + lv_obj_set_state(ctx->outputEnabledSwitch, LV_STATE_CHECKED, enabled); + lv_obj_set_flag(ctx->outputCard, LV_OBJ_FLAG_HIDDEN, !enabled); } if (ctx->outputMuteSwitch) { if (service::audio::isOutputMuted()) lv_obj_add_state(ctx->outputMuteSwitch, LV_STATE_CHECKED); @@ -177,17 +200,19 @@ void createWidgets(lv_obj_t* parent, void* userData) { // device (e.g. a dedicated input codec with no output codec bound) should // only show the section it actually has, not a dead section for the other. if (service::audio::isInputAvailable()) { - auto* input_section = createSection(main_wrapper, "Microphone"); - ctx->inputEnabledSwitch = createSwitchRow(input_section, "Enabled", onInputEnabledSwitch, ctx); - ctx->inputMuteSwitch = createSwitchRow(input_section, "Mute", onInputMuteSwitch, ctx); - ctx->inputVolumeSlider = createSliderRow(input_section, "Volume", static_cast(service::audio::getInputVolume()), onInputVolumeSlider, ctx); + const auto input_section = createSection(main_wrapper, "Microphone", service::audio::isInputEnabled(), onInputEnabledSwitch, ctx); + ctx->inputEnabledSwitch = input_section.enabledSwitch; + ctx->inputCard = input_section.card; + ctx->inputMuteSwitch = createSwitchRow(input_section.card, "Mute", onInputMuteSwitch, ctx); + ctx->inputVolumeSlider = createSliderRow(input_section.card, "Volume", static_cast(service::audio::getInputVolume()), onInputVolumeSlider, ctx); } if (service::audio::isOutputAvailable()) { - auto* output_section = createSection(main_wrapper, "Speaker"); - ctx->outputEnabledSwitch = createSwitchRow(output_section, "Enabled", onOutputEnabledSwitch, ctx); - ctx->outputMuteSwitch = createSwitchRow(output_section, "Mute", onOutputMuteSwitch, ctx); - ctx->outputVolumeSlider = createSliderRow(output_section, "Volume", static_cast(service::audio::getOutputVolume()), onOutputVolumeSlider, ctx); + const auto output_section = createSection(main_wrapper, "Speaker", service::audio::isOutputEnabled(), onOutputEnabledSwitch, ctx); + ctx->outputEnabledSwitch = output_section.enabledSwitch; + ctx->outputCard = output_section.card; + ctx->outputMuteSwitch = createSwitchRow(output_section.card, "Mute", onOutputMuteSwitch, ctx); + ctx->outputVolumeSlider = createSliderRow(output_section.card, "Volume", static_cast(service::audio::getOutputVolume()), onOutputVolumeSlider, ctx); } // isAvailable() only reflects that the audio-stream device exists, not that any diff --git a/Tactility/Source/app/files/State.cpp b/Tactility/Source/app/files/State.cpp index 07668f319..7049a43a0 100644 --- a/Tactility/Source/app/files/State.cpp +++ b/Tactility/Source/app/files/State.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -14,7 +15,10 @@ namespace tt::app::files { constexpr auto* TAG = "Files"; State::State() { - setEntriesForPath("/"); + char data_root[FILE_MAX_PATH_STRING_LENGTH]; + if (paths_get_data_root_path(data_root, sizeof(data_root)) != ERROR_NONE || !setEntriesForPath(data_root)) { + setEntriesForPath("/"); + } } std::string State::getSelectedChildPath() const { diff --git a/Tactility/Source/app/launcher/Launcher.cpp b/Tactility/Source/app/launcher/Launcher.cpp index 7508afc72..ee19d490b 100644 --- a/Tactility/Source/app/launcher/Launcher.cpp +++ b/Tactility/Source/app/launcher/Launcher.cpp @@ -1,16 +1,19 @@ #include #include #include +#include #include #include +#include #include +#include +#include #include -#include #include -#include -#include +#include +#include #include #include @@ -18,13 +21,11 @@ #include #include -#include #include -#include -#include #include -#include +#include +#include #include #include #include @@ -35,32 +36,107 @@ constexpr auto* TAG = "Launcher"; namespace { -int32_t computeButtonMargin(int32_t available_span, int32_t total_button_size) { - const int32_t usable = std::max(0, available_span - (3 * total_button_size)); - return std::min(usable / 16, total_button_size / 2); +struct IconEntry { + const char* id; + const char* icon; +}; + +constexpr IconEntry ICONS[] = { + {"tactility.apphub", LVGL_ICON_SHARED_DOWNLOAD}, + {"tactility.camera", LVGL_ICON_SHARED_CAMERA}, + {"tactility.chat", LVGL_ICON_SHARED_FORUM}, + {"tactility.files", LVGL_ICON_SHARED_FOLDER}, + {"tactility.i2cscanner", LVGL_ICON_SHARED_CABLE}, + {"tactility.notes", LVGL_ICON_SHARED_EDIT_NOTE}, + {"tactility.screenshot", LVGL_ICON_SHARED_IMAGE}, + {"tactility.settings", LVGL_ICON_SHARED_SETTINGS}, + {"tactility.systeminfo", LVGL_ICON_SHARED_DEVICES}, + {"tactility.terminal", LVGL_ICON_SHARED_TERMINAL}, + {"tactility.webserversettings", LVGL_ICON_SHARED_CLOUD}, +}; + +// Hidden apps that are still shown in the launcher +constexpr const char* SHOWN_HIDDEN_APP_IDS[] = { + "tactility.files", + "tactility.settings", +}; + +const char* appIcon(const ::AppManifest* manifest) { + for (const auto& entry : ICONS) { + if (!strcmp(manifest->id, entry.id)) return entry.icon; + } + return LVGL_ICON_SHARED_DEPLOYED_CODE; } -// Spreads the buttons along the display's longest side. The button padding comes from the theme. -void applyButtonMargins(lv_obj_t* buttonsWrapper, bool isLandscape) { - const uint32_t child_count = lv_obj_get_child_count(buttonsWrapper); - if (child_count == 0) { +bool isShown(const ::AppManifest& manifest) { + if (manifest.category != APP_CATEGORY_USER && manifest.category != APP_CATEGORY_SYSTEM) { + return false; + } + if ((manifest.flags & APP_MANIFEST_FLAG_HIDDEN) == 0) { + return true; + } + return std::ranges::any_of(SHOWN_HIDDEN_APP_IDS, [&](const char* id) { return strcmp(manifest.id, id) == 0; }); +} + +void collectManifest(const ::AppManifest* manifest, void* context) { + auto* manifests = static_cast*>(context); + manifests->push_back(*manifest); +} + +// Apps from installed packages this device can't run (wrong device, too little RAM) +void collectIncompatibleAppIds(const AppPackage* package, void* context) { + if (app_package_manifest_is_compatible(&package->package)) { return; } - auto* first_button = lv_obj_get_child(buttonsWrapper, 0); - const auto total_button_size = static_cast(lvgl_get_launcher_icon_font_height()) + - lv_obj_get_style_pad_left(first_button, LV_PART_MAIN) + lv_obj_get_style_pad_right(first_button, LV_PART_MAIN); - const auto* display = lv_obj_get_display(buttonsWrapper); - const auto span = isLandscape ? lv_display_get_horizontal_resolution(display) : lv_display_get_vertical_resolution(display); - const int32_t margin = computeButtonMargin(span, total_button_size); - for (uint32_t i = 0; i < child_count; i++) { - auto* button = lv_obj_get_child(buttonsWrapper, i); - lv_obj_set_style_margin_hor(button, isLandscape ? margin : 0, LV_STATE_DEFAULT); - lv_obj_set_style_margin_ver(button, isLandscape ? 0 : margin, LV_STATE_DEFAULT); + auto* ids = static_cast*>(context); + for (size_t i = 0; i < package->app_id_count; i++) { + ids->emplace_back(package->app_ids[i]); } } -void onAppPressed(lv_event_t* e) { - auto* appId = static_cast(lv_event_get_user_data(e)); +std::vector collectItems(void* userData); +void onAppClicked(const ::AppManifest& manifest, void* userData); +void onAppLongPressed(const ::AppManifest& manifest, void* userData); +void onAppKey(const ::AppManifest& manifest, uint32_t key, void* userData); + +struct Context { + Favourites favourites; + AppGrid grid { AppGrid::Callbacks { + .collect = collectItems, + .onClicked = onAppClicked, + .onLongPressed = onAppLongPressed, + .onKey = onAppKey, + .userData = this + } }; +}; + +std::vector collectItems(void* userData) { + const auto* ctx = static_cast(userData); + const std::vector favouriteIds = ctx->favourites.load(); + + std::vector<::AppManifest> collected; + app_manager_for_each_manifest(collectManifest, &collected); + std::vector incompatibleIds; + app_manager_for_each_package(collectIncompatibleAppIds, &incompatibleIds); + std::erase_if(collected, [&](const ::AppManifest& manifest) { + return !isShown(manifest) || std::ranges::find(incompatibleIds, std::string(manifest.id)) != incompatibleIds.end(); + }); + + std::vector items; + items.reserve(collected.size()); + for (const auto& manifest : collected) { + items.push_back({ manifest, appIcon(&manifest), Favourites::contains(favouriteIds, manifest.id) }); + } + std::ranges::sort(items, [](const AppGridItem& a, const AppGridItem& b) { + if (a.highlighted != b.highlighted) { + return a.highlighted; + } + return strcmp(a.manifest.name, b.manifest.name) < 0; + }); + return items; +} + +void startApp(const char* appId) { uint32_t instance_id = 0; AppStartContext context; if (app_start_context_from_id(appId, &context) == ERROR_NONE) { @@ -68,26 +144,39 @@ void onAppPressed(lv_event_t* e) { } } -lv_obj_t* createAppButton(lv_obj_t* parent, LvglIconButtonVariant variant, const char* imageFile, const char* appId) { - const auto button_size = lvgl_get_launcher_icon_font_height(); - auto* apps_button = lvgl_icon_button_create_variant(parent, variant); - - // create the image first - auto* button_image = lv_image_create(apps_button); - lv_obj_set_style_text_font(button_image, lvgl_get_launcher_icon_font(), LV_STATE_DEFAULT); - lv_image_set_src(button_image, imageFile); - - // Ensure it's square (Material Symbols are slightly wider than tall) - lv_obj_set_size(button_image, button_size, button_size); - - lv_obj_add_event_cb(apps_button, onAppPressed, LV_EVENT_SHORT_CLICKED, (void*)appId); - - return apps_button; +void onAppClicked(const ::AppManifest& manifest, void*) { + startApp(manifest.id); } -bool shouldShowPowerButton() { - bool show_power_button = false; - device_for_each_of_type(&POWER_SUPPLY_TYPE, &show_power_button, [](Device* device, void* context) { +void toggleFavourite(Context* ctx, const char* appId, bool keepSelection) { + if (!ctx->favourites.toggle(appId)) { + LOG_E(TAG, "Failed to save favourites"); + } + ctx->grid.requestRepopulate(keepSelection); +} + +void onAppLongPressed(const ::AppManifest& manifest, void* userData) { + toggleFavourite(static_cast(userData), manifest.id, false); +} + +void onAppKey(const ::AppManifest& manifest, uint32_t key, void* userData) { + if (key == 'f' || key == 'F') { + toggleFavourite(static_cast(userData), manifest.id, true); + } +} + +void onShortcutPressed(lv_event_t* e) { + startApp(static_cast(lv_event_get_user_data(e))); +} + +bool isAppRegistered(const char* appId) { + ::AppManifest manifest; + return app_manager_find_manifest(appId, &manifest) == ERROR_NONE; +} + +bool supportsPowerOff() { + bool supported = false; + device_for_each_of_type(&POWER_SUPPLY_TYPE, &supported, [](Device* device, void* context) { if (device_is_ready(device) && power_supply_supports_power_off(device)) { *static_cast(context) = true; return false; // stop iterating @@ -95,98 +184,49 @@ bool shouldShowPowerButton() { return true; // continue iterating } }); - return show_power_button; + return supported; } -void onButtonsWrapperResized(lv_event_t* e); - -// The screen object outlives this window's own widgets (lvgl-window-manager deletes and -// recreates only the topmost window's widget on every app switch, not the screen itself), so -// the LV_EVENT_SIZE_CHANGED callback registered on it must be removed once buttons_wrapper is -// destroyed, to avoid a dangling user-data pointer the next time the display rotates while a -// different window is topmost. -void onButtonsWrapperDeleted(lv_event_t* e) { - auto* buttons_wrapper = lv_event_get_target_obj(e); - auto* screen = lv_obj_get_screen(buttons_wrapper); - lv_obj_remove_event_cb_with_user_data(screen, onButtonsWrapperResized, buttons_wrapper); +void addShortcut(lv_obj_t* parent, const char* icon, const char* appId) { + auto* button = lvgl_icon_button_create(parent); + auto* label = lv_label_create(button); + lv_obj_set_style_text_font(label, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); + lv_label_set_text(label, icon); + lv_obj_add_event_cb(button, onShortcutPressed, LV_EVENT_SHORT_CLICKED, const_cast(appId)); } -// Re-applies the flex direction and per-button margins when the display orientation changes -// while the launcher is the visible window (these are decided once at createWidgets() based on -// the resolution at that time, so a later rotation needs this to catch up). -void onButtonsWrapperResized(lv_event_t* e) { - auto* buttons_wrapper = static_cast(lv_event_get_user_data(e)); - const auto* display = lv_obj_get_display(buttons_wrapper); - - const auto horizontal_px = lv_display_get_horizontal_resolution(display); - const auto vertical_px = lv_display_get_vertical_resolution(display); - const bool is_landscape_display = horizontal_px >= vertical_px; - const auto current_flow = lv_obj_get_style_flex_flow(buttons_wrapper, LV_PART_MAIN); - const bool was_landscape = current_flow == LV_FLEX_FLOW_ROW; - if (is_landscape_display == was_landscape) { - return; - } - - lv_obj_set_flex_flow(buttons_wrapper, is_landscape_display ? LV_FLEX_FLOW_ROW : LV_FLEX_FLOW_COLUMN); - applyButtonMargins(buttons_wrapper, is_landscape_display); +lv_obj_t* createBarSection(lv_obj_t* parent, lv_flex_align_t align) { + auto* section = lv_obj_create(parent); + lv_obj_set_size(section, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(section, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(section, 0, LV_STATE_DEFAULT); + lv_obj_set_flex_flow(section, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(section, align, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_remove_flag(section, LV_OBJ_FLAG_SCROLLABLE); + return section; } -void createWidgets(lv_obj_t* parent, void*) { - auto* buttons_wrapper = lv_obj_create(parent); +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); - lv_obj_align(buttons_wrapper, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_size(buttons_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_set_style_border_width(buttons_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_flex_grow(buttons_wrapper, 1); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - // Fix for button selection - lv_obj_set_style_pad_all(buttons_wrapper, 6, LV_STATE_DEFAULT); + // Power off on the left, the page number and page buttons on the right + auto* bottom_bar = createBarSection(parent, LV_FLEX_ALIGN_START); + lv_obj_set_width(bottom_bar, LV_PCT(100)); + lvgl_obj_add_edge_padding(bottom_bar); + auto* left_section = createBarSection(bottom_bar, LV_FLEX_ALIGN_START); + lv_obj_set_flex_grow(left_section, 1); + auto* page_bar = createBarSection(bottom_bar, LV_FLEX_ALIGN_END); - const auto* display = lv_obj_get_display(parent); - const auto horizontal_px = lv_display_get_horizontal_resolution(display); - const auto vertical_px = lv_display_get_vertical_resolution(display); - const bool is_landscape_display = horizontal_px >= vertical_px; - if (is_landscape_display) { - lv_obj_set_flex_flow(buttons_wrapper, LV_FLEX_FLOW_ROW); - } else { - lv_obj_set_flex_flow(buttons_wrapper, LV_FLEX_FLOW_COLUMN); - } + // The monochrome theme's primary color is black, also on a black background + ctx->grid.setPrimaryColorIcons(!lvgl_theme_is_mono()); + ctx->grid.createWidgetsWithPageBar(parent, page_bar); + lv_obj_move_foreground(bottom_bar); - // The app list is the main action, so it stands out more than the others - // Filled buttons would be solid black blocks with the monochrome theme - const auto variant = lvgl_theme_is_mono() ? LVGL_ICON_BUTTON_STANDARD : LVGL_ICON_BUTTON_FILLED; - auto* app_list_button = createAppButton(buttons_wrapper, variant, LVGL_ICON_LAUNCHER_APPS, "tactility.applist"); - createAppButton(buttons_wrapper, variant, LVGL_ICON_LAUNCHER_FOLDER, "tactility.files"); - createAppButton(buttons_wrapper, variant, LVGL_ICON_LAUNCHER_SETTINGS, "tactility.settings"); - applyButtonMargins(buttons_wrapper, is_landscape_display); - // The arrow keys move between the buttons, in a row or a column - lvgl_grid_navigation_add(buttons_wrapper); - - // The launcher's container is several levels below the screen, and LVGL only sends - // LV_EVENT_SIZE_CHANGED to the screen object itself on a resolution change - so the - // handler is attached there, with buttons_wrapper passed through as user data. - lv_obj_add_event_cb(lv_obj_get_screen(parent), onButtonsWrapperResized, LV_EVENT_SIZE_CHANGED, buttons_wrapper); - lv_obj_add_event_cb(buttons_wrapper, onButtonsWrapperDeleted, LV_EVENT_DELETE, nullptr); - - // Some devices (e.g. T-Lora Pager) have no other way to power off, so the - // button stays in the launcher; the confirmation flow lives in the PowerOff app. - if (shouldShowPowerButton()) { - auto* power_button = lvgl_icon_button_create(parent); - lv_obj_set_style_pad_all(power_button, 8, 0); - lv_obj_align(power_button, LV_ALIGN_BOTTOM_MID, 0, -10); - lv_obj_add_event_cb(power_button, onAppPressed, LV_EVENT_SHORT_CLICKED, (void*)"tactility.poweroff"); - - auto* power_label = lv_label_create(power_button); - lv_label_set_text(power_label, LV_SYMBOL_POWER); - } - - // If we don't have a touch device, we assume there's some other kind of input like a keyboard, an encoder or button control - // In that scenario we want to automatically have the app list button selected so the user doesn't have to press the widget selection - // an extra time. - if (!device_has_active_by_type(&POINTER_TYPE)) { - // lv_obj_update_layout(parent); // Resolve flex layout first, so focus/state invalidate against final coords - lv_group_focus_obj(buttons_wrapper); - lv_gridnav_set_focused(buttons_wrapper, app_list_button, LV_ANIM_OFF); + if (isAppRegistered("tactility.poweroff") && supportsPowerOff()) { + addShortcut(left_section, LVGL_ICON_SHARED_POWER_SETTINGS_NEW, "tactility.poweroff"); } } @@ -218,30 +258,6 @@ void runAutoStart() { } } -constexpr size_t AUTO_START_STACK_DEPTH = 4096 / sizeof(StackType_t); - -struct AutoStartTaskContext { - SemaphoreHandle_t done; -}; - -void autoStartTaskMain(void* param) { - auto* context = static_cast(param); - runAutoStart(); - xSemaphoreGive(context->done); - vTaskDelete(nullptr); -} - -// runAutoStart() reads from flash, and apps run on PSRAM when available. -// Launcher stays in memory, so we prefer to keep the PSRAM task and temporarily run an IRAM task for auto start logic. -void runAutoStartIsolated() { - AutoStartTaskContext context { .done = xSemaphoreCreateBinary() }; - check(context.done != nullptr); - TaskHandle_t auto_start_task = nullptr; - check(xTaskCreate(autoStartTaskMain, "LauncherAutoStart", AUTO_START_STACK_DEPTH, &context, tskIDLE_PRIORITY, &auto_start_task) == pdPASS); - xSemaphoreTake(context.done, portMAX_DELAY); - vSemaphoreDelete(context.done); -} - int32_t appMain(int argc, char* argv[]) { uint32_t appInstanceId = app_scheduler_current_app_id(); @@ -251,9 +267,10 @@ 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, nullptr); + Context ctx; + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - runAutoStartIsolated(); + runAutoStart(); // The launcher is meant to stay resident (it's the home screen) - it only gives up its // thread when app-module's scheduler asks it to (e.g. another new-model app is started). @@ -285,8 +302,7 @@ extern const ::AppManifest manifest = { .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(appMain) }, .flags = APP_MANIFEST_FLAG_HIDDEN, - // No file IO, so callstack can be in external RAM - .stack = { .depth = 3072 , .desired_memory_capability = MEMORY_CAPABILITY_EXTERNAL } + .stack = { .depth = 5120, .desired_memory_capability = 0 } }; // Kept for Tactility/Private/Tactility/app/launcher/Launcher.h's existing declaration (still diff --git a/Tactility/Source/app/settings/Settings.cpp b/Tactility/Source/app/settings/Settings.cpp index 6c25f9be4..d8f5e91f0 100644 --- a/Tactility/Source/app/settings/Settings.cpp +++ b/Tactility/Source/app/settings/Settings.cpp @@ -55,7 +55,7 @@ const char* appIcon(const ::AppManifest* manifest) { } void onAppClicked(const ::AppManifest& manifest, void*) { - // Fire-and-forget top-level navigation, same as AppList's own app-launch buttons. + // Fire-and-forget top-level navigation, same as the Launcher's own app-launch buttons. uint32_t instanceId = 0; // Re-resolved by id against the live ledger, not the (possibly stale) cached manifest above: // fails gracefully if the app was uninstalled since this button was built, instead of handing @@ -154,7 +154,7 @@ extern const ::AppManifest manifest = { .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(appMain) }, .flags = APP_MANIFEST_FLAG_HIDDEN, - .stack = { .depth = 4000, .desired_memory_capability = 0 }, + .stack = { .depth = 5120, .desired_memory_capability = 0 }, }; } // namespace diff --git a/Tactility/Source/app/systeminfo/SystemInfo.cpp b/Tactility/Source/app/systeminfo/SystemInfo.cpp index f5c4cb5e8..cf1c545bb 100644 --- a/Tactility/Source/app/systeminfo/SystemInfo.cpp +++ b/Tactility/Source/app/systeminfo/SystemInfo.cpp @@ -131,6 +131,7 @@ MemoryBarWidgets createMemoryBar(lv_obj_t* parent, const char* label) { lv_obj_set_style_pad_all(container, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(container, 0, LV_STATE_DEFAULT); lv_obj_set_flex_flow(container, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(container, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_bg_opa(container, 0, LV_STATE_DEFAULT); auto* left_label = lv_label_create(container); diff --git a/Tactility/Source/app/terminal/main.cpp b/Tactility/Source/app/terminal/main.cpp index 57b3c7efa..95d6ab67f 100644 --- a/Tactility/Source/app/terminal/main.cpp +++ b/Tactility/Source/app/terminal/main.cpp @@ -40,6 +40,11 @@ void onClosePressed(lv_event_t* event) { app_event_emit_close(ctx->appInstanceId); } +void onCanvasClicked(lv_event_t* event) { + auto* toolbar = static_cast(lv_event_get_user_data(event)); + lv_obj_set_flag(toolbar, LV_OBJ_FLAG_HIDDEN, !lv_obj_has_flag(toolbar, LV_OBJ_FLAG_HIDDEN)); +} + void createWidgets(lv_obj_t* root, void* userData) { auto* ctx = static_cast(userData); @@ -47,16 +52,21 @@ void createWidgets(lv_obj_t* root, void* userData) { lv_obj_set_style_pad_all(root, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_row(root, 0, LV_STATE_DEFAULT); - // The toolbar only offers a touch close button, so it is left out to give the terminal more room - if (lvgl_indev_exists(LV_INDEV_TYPE_POINTER)) { - auto* toolbar = lvgl_toolbar_create(root, "Terminal"); - lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onClosePressed, ctx); - } - auto* canvas = lv_canvas_create(root); lv_obj_set_width(canvas, LV_PCT(100)); lv_obj_set_flex_grow(canvas, 1); + // The toolbar only offers a touch close button. It's hidden to give the terminal all the room, and touching the terminal shows it on top. + if (lvgl_indev_exists(LV_INDEV_TYPE_POINTER)) { + auto* toolbar = lvgl_toolbar_create(root, "Terminal"); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onClosePressed, ctx); + lv_obj_add_flag(toolbar, LV_OBJ_FLAG_FLOATING); + lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_add_flag(toolbar, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(canvas, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(canvas, onCanvasClicked, LV_EVENT_SHORT_CLICKED, toolbar); + } + ctx->renderer.attachCanvas(canvas); } diff --git a/Tactility/Source/app/trackball/TrackballSettings.cpp b/Tactility/Source/app/trackball/TrackballSettings.cpp index a1fa6c8de..e5d2249a2 100644 --- a/Tactility/Source/app/trackball/TrackballSettings.cpp +++ b/Tactility/Source/app/trackball/TrackballSettings.cpp @@ -25,13 +25,13 @@ extern const ::AppManifest manifest; constexpr auto* TAG = "TrackballSettings"; -// Convert mode to dropdown index (dropdown order: Encoder=0, Pointer=1) +// Convert mode to dropdown index (dropdown order: Keys=0, Pointer=1) static uint32_t modeToDropdownIndex(LvglTrackballMode mode) { switch (mode) { - case LVGL_TRACKBALL_MODE_ENCODER: return 0; + case LVGL_TRACKBALL_MODE_KEYS: return 0; case LVGL_TRACKBALL_MODE_POINTER: return 1; } - return 0; // default to Encoder + return 0; // default to Keys } static lv_indev_t* findFirstTrackballIndev() { @@ -65,7 +65,7 @@ struct Context { lv_indev_t* trackballIndev = nullptr; lv_obj_t* switchTrackball = nullptr; lv_obj_t* trackballModeDropdown = nullptr; - lv_obj_t* encoderSensitivitySlider = nullptr; + lv_obj_t* keySensitivitySlider = nullptr; lv_obj_t* pointerSensitivitySlider = nullptr; }; @@ -97,11 +97,11 @@ void onTrackballSwitch(lv_event_t* e) { // Enable/disable controls based on trackball state if (enabled) { if (ctx->trackballModeDropdown) lv_obj_clear_state(ctx->trackballModeDropdown, LV_STATE_DISABLED); - if (ctx->encoderSensitivitySlider) lv_obj_clear_state(ctx->encoderSensitivitySlider, LV_STATE_DISABLED); + if (ctx->keySensitivitySlider) lv_obj_clear_state(ctx->keySensitivitySlider, LV_STATE_DISABLED); if (ctx->pointerSensitivitySlider) lv_obj_clear_state(ctx->pointerSensitivitySlider, LV_STATE_DISABLED); } else { if (ctx->trackballModeDropdown) lv_obj_add_state(ctx->trackballModeDropdown, LV_STATE_DISABLED); - if (ctx->encoderSensitivitySlider) lv_obj_add_state(ctx->encoderSensitivitySlider, LV_STATE_DISABLED); + if (ctx->keySensitivitySlider) lv_obj_add_state(ctx->keySensitivitySlider, LV_STATE_DISABLED); if (ctx->pointerSensitivitySlider) lv_obj_add_state(ctx->pointerSensitivitySlider, LV_STATE_DISABLED); } } @@ -110,10 +110,10 @@ void onTrackballModeChanged(lv_event_t* e) { auto* ctx = static_cast(lv_event_get_user_data(e)); uint32_t selected = lv_dropdown_get_selected(ctx->trackballModeDropdown); - // Validate selection matches expected enum values (dropdown order: Encoder=0, Pointer=1) + // Validate selection matches expected enum values (dropdown order: Keys=0, Pointer=1) LvglTrackballMode mode; switch (selected) { - case 0: mode = LVGL_TRACKBALL_MODE_ENCODER; break; + case 0: mode = LVGL_TRACKBALL_MODE_KEYS; break; case 1: mode = LVGL_TRACKBALL_MODE_POINTER; break; default: return; // Invalid selection, ignore } @@ -125,10 +125,10 @@ void onTrackballModeChanged(lv_event_t* e) { applyLive(ctx); } -void onEncoderSensitivityChanged(lv_event_t* e) { +void onKeySensitivityChanged(lv_event_t* e) { auto* ctx = static_cast(lv_event_get_user_data(e)); - int32_t value = lv_slider_get_value(ctx->encoderSensitivitySlider); - ctx->tbSettings.encoder_sensitivity = static_cast(value); + int32_t value = lv_slider_get_value(ctx->keySensitivitySlider); + ctx->tbSettings.key_sensitivity = static_cast(value); ctx->updated = true; // Apply immediately @@ -195,7 +195,7 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_obj_align(tb_mode_label, LV_ALIGN_LEFT_MID, 0, 0); ctx->trackballModeDropdown = lv_dropdown_create(tb_mode_wrapper); - lv_dropdown_set_options(ctx->trackballModeDropdown, "Encoder\nPointer"); + lv_dropdown_set_options(ctx->trackballModeDropdown, "Keys\nPointer"); lv_obj_align(ctx->trackballModeDropdown, LV_ALIGN_RIGHT_MID, 0, 0); lv_dropdown_set_selected(ctx->trackballModeDropdown, modeToDropdownIndex(ctx->tbSettings.mode)); lv_obj_add_event_cb(ctx->trackballModeDropdown, onTrackballModeChanged, LV_EVENT_VALUE_CHANGED, ctx); @@ -205,25 +205,25 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_obj_add_state(ctx->trackballModeDropdown, LV_STATE_DISABLED); } - // Encoder sensitivity slider - auto* enc_sens_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(enc_sens_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_hor(enc_sens_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(enc_sens_wrapper, 0, LV_STATE_DEFAULT); + // Key sensitivity slider + auto* key_sens_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(key_sens_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_hor(key_sens_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(key_sens_wrapper, 0, LV_STATE_DEFAULT); - auto* enc_sens_label = lv_label_create(enc_sens_wrapper); - lv_label_set_text(enc_sens_label, "Encoder Speed"); - lv_obj_align(enc_sens_label, LV_ALIGN_LEFT_MID, 0, 0); + auto* key_sens_label = lv_label_create(key_sens_wrapper); + lv_label_set_text(key_sens_label, "Key Speed"); + lv_obj_align(key_sens_label, LV_ALIGN_LEFT_MID, 0, 0); - ctx->encoderSensitivitySlider = lv_slider_create(enc_sens_wrapper); - lv_slider_set_range(ctx->encoderSensitivitySlider, 1, 10); - lv_slider_set_value(ctx->encoderSensitivitySlider, ctx->tbSettings.encoder_sensitivity, LV_ANIM_OFF); - lv_obj_set_width(ctx->encoderSensitivitySlider, LV_PCT(50)); - lv_obj_align(ctx->encoderSensitivitySlider, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(ctx->encoderSensitivitySlider, onEncoderSensitivityChanged, LV_EVENT_VALUE_CHANGED, ctx); + ctx->keySensitivitySlider = lv_slider_create(key_sens_wrapper); + lv_slider_set_range(ctx->keySensitivitySlider, 1, 10); + lv_slider_set_value(ctx->keySensitivitySlider, ctx->tbSettings.key_sensitivity, LV_ANIM_OFF); + lv_obj_set_width(ctx->keySensitivitySlider, LV_PCT(50)); + lv_obj_align(ctx->keySensitivitySlider, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(ctx->keySensitivitySlider, onKeySensitivityChanged, LV_EVENT_VALUE_CHANGED, ctx); if (!ctx->tbSettings.enabled) { - lv_obj_add_state(ctx->encoderSensitivitySlider, LV_STATE_DISABLED); + lv_obj_add_state(ctx->keySensitivitySlider, LV_STATE_DISABLED); } // Pointer sensitivity slider diff --git a/Tactility/Source/lvgl/Lvgl.cpp b/Tactility/Source/lvgl/Lvgl.cpp index 2be29297a..6e084aeca 100644 --- a/Tactility/Source/lvgl/Lvgl.cpp +++ b/Tactility/Source/lvgl/Lvgl.cpp @@ -1,5 +1,7 @@ #include +#include + #ifdef ESP_PLATFORM #include #endif @@ -22,6 +24,7 @@ #include #include +#include #include #include #include @@ -79,7 +82,8 @@ static lv_obj_t* windowManagerScreenInit(lv_obj_t* root) { lv_obj_set_style_radius(vertical_container, 0, LV_STATE_DEFAULT); lv_obj_remove_flag(vertical_container, LV_OBJ_FLAG_SCROLLABLE); - lvgl::statusbar_create(vertical_container); + // The statusbar fits itself into the top of round and rounded displays + auto* statusbar = lvgl::statusbar_create(vertical_container); auto* app_container = lv_obj_create(vertical_container); lv_obj_set_style_pad_all(app_container, 0, LV_STATE_DEFAULT); @@ -89,6 +93,21 @@ static lv_obj_t* windowManagerScreenInit(lv_obj_t* root) { lv_obj_set_flex_flow(app_container, LV_FLEX_FLOW_COLUMN); lv_obj_remove_flag(app_container, LV_OBJ_FLAG_SCROLLABLE); + // Keeps the apps inside the visible area of round displays. + // On rounded displays, the widgets along the edges move their content inward from the corners instead. + lv_display_t* display = lv_obj_get_display(root); + DisplayShape shape; + lvgl_display_get_shape(display, &shape); + if (shape.shape == DISPLAY_SHAPE_CIRCLE) { + LvglInsets insets; + lvgl_display_get_insets(display, &insets); + lv_obj_update_layout(statusbar); + lv_obj_set_style_pad_top(app_container, std::max(0, insets.top - lv_obj_get_height(statusbar)), LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(app_container, insets.bottom, LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(app_container, insets.left, LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(app_container, insets.right, LV_STATE_DEFAULT); + } + // Parented to root (not app_container/vertical_container) so it overlays on top of // everything, including the statusbar, regardless of which app is showing. Hidden until a // focused textarea shows it (see lvgl_keyboard_add_textarea()/textarea_show_keyboard()). diff --git a/Tactility/Source/lvgl/Statusbar.cpp b/Tactility/Source/lvgl/Statusbar.cpp index 5709c3fde..9d00a2e49 100644 --- a/Tactility/Source/lvgl/Statusbar.cpp +++ b/Tactility/Source/lvgl/Statusbar.cpp @@ -12,9 +12,16 @@ #include #include -#include -#include +#include +#include +#include +#include +#include +#include +#include + +#include #include #ifdef ESP_PLATFORM @@ -35,6 +42,7 @@ static void onUpdateTime(); struct StatusbarIcon { std::string image; + std::string appId; bool visible = false; bool claimed = false; }; @@ -159,6 +167,22 @@ static void update_icon(lv_obj_t* image, const StatusbarIcon* icon) { } else { lv_obj_add_flag(image, LV_OBJ_FLAG_HIDDEN); } + lv_obj_set_flag(image, LV_OBJ_FLAG_CLICKABLE, !icon->appId.empty()); +} + +static void onIconClicked(lv_event_t* event) { + const auto index = reinterpret_cast(lv_event_get_user_data(event)); + statusbar_data.mutex.lock(); + const std::string app_id = statusbar_data.icons[index].appId; + statusbar_data.mutex.unlock(); + if (app_id.empty()) { + return; + } + AppStartContext context; + uint32_t instance_id = 0; + if (app_start_context_from_id(app_id.c_str(), &context) == ERROR_NONE) { + app_start_with_context(&context, &instance_id); + } } lv_obj_t* statusbar_create(lv_obj_t* parent) { @@ -168,42 +192,81 @@ lv_obj_t* statusbar_create(lv_obj_t* parent) { auto* statusbar = reinterpret_cast(obj); - // The statusbar has no theme styles: it shows the themed container background behind it and inherits its text colour - if (STATUSBAR_COLORS_INVERTED) { - // A horizontal line separates the statusbar from the rest of the UI below it - lv_obj_set_style_border_color(obj, lv_obj_get_style_text_color(obj, LV_PART_MAIN), LV_STATE_DEFAULT); - lv_obj_set_style_border_side(obj, LV_BORDER_SIDE_BOTTOM, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(obj, 1, LV_STATE_DEFAULT); - } - lv_obj_set_width(obj, LV_PCT(100)); lv_obj_set_style_pad_ver(obj, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_hor(obj, 2, LV_STATE_DEFAULT); lv_obj_center(obj); - lv_obj_set_flex_flow(obj, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(obj, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); auto icon_size = lvgl_get_statusbar_icon_font_height(); auto ui_density = lvgl_get_ui_density(); auto icon_padding = (ui_density != LVGL_UI_DENSITY_COMPACT) ? static_cast(icon_size * 0.2f) : 2; - lv_obj_set_style_pad_column(obj, icon_padding, LV_STATE_DEFAULT); - statusbar->time = lv_label_create(obj); - lv_obj_set_style_margin_left(statusbar->time, 4, LV_STATE_DEFAULT); - update_time(statusbar); + lv_display_t* display = lv_obj_get_display(obj); + DisplayShape shape; + lvgl_display_get_shape(display, &shape); - auto* left_spacer = lv_obj_create(obj); - lv_obj_remove_flag(left_spacer, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(left_spacer, 1, 1); - lv_obj_set_flex_grow(left_spacer, 1); - lv_obj_set_style_bg_opa(left_spacer, LV_OPA_0, LV_STATE_DEFAULT); - lv_obj_set_style_border_opa(left_spacer, LV_OPA_0, LV_STATE_DEFAULT); + // The parent of the icons, which grid navigation moves through + lv_obj_t* icon_row; + if (shape.shape == DISPLAY_SHAPE_CIRCLE) { + // Two centered rows, as the top of a circle is narrow: the time, and the icons below it + const int32_t diameter = std::min(lv_display_get_horizontal_resolution(display), lv_display_get_vertical_resolution(display)); + lv_obj_set_height(obj, LV_SIZE_CONTENT); + lv_obj_set_style_pad_top(obj, diameter / 24, LV_STATE_DEFAULT); + lv_obj_set_flex_flow(obj, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(obj, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + statusbar->time = lv_label_create(obj); + update_time(statusbar); + + icon_row = lv_obj_create(obj); + lv_obj_remove_style_all(icon_row); + lv_obj_set_size(icon_row, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_pad_ver(icon_row, static_cast(icon_size / 10), LV_STATE_DEFAULT); + lv_obj_set_style_pad_column(icon_row, icon_padding, LV_STATE_DEFAULT); + lv_obj_set_flex_flow(icon_row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(icon_row, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + } else { + lv_obj_set_flex_flow(obj, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(obj, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(obj, icon_padding, LV_STATE_DEFAULT); + + if (shape.shape == DISPLAY_SHAPE_ROUNDED) { + // Moves the content inward from the rounded corners, measured at the top of the icons + const int32_t content_top = (statusbar_get_height() - static_cast(icon_size)) / 2; + lv_obj_set_style_pad_hor(obj, 2 + lvgl_display_get_row_inset(display, content_top), LV_STATE_DEFAULT); + } + + statusbar->time = lv_label_create(obj); + lv_obj_set_style_margin_left(statusbar->time, 4, LV_STATE_DEFAULT); + update_time(statusbar); + + auto* left_spacer = lv_obj_create(obj); + lv_obj_remove_flag(left_spacer, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(left_spacer, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(left_spacer, 1, 1); + lv_obj_set_flex_grow(left_spacer, 1); + lv_obj_set_style_bg_opa(left_spacer, LV_OPA_0, LV_STATE_DEFAULT); + lv_obj_set_style_border_opa(left_spacer, LV_OPA_0, LV_STATE_DEFAULT); + + icon_row = obj; + } + + // The monochrome theme's focus ring doesn't contrast with the statusbar, so the icons get their own + const bool own_focus_ring = lvgl_theme_is_mono(); + const int32_t focus_width = (ui_density != LVGL_UI_DENSITY_COMPACT) ? 2 : 1; + const lv_color_t focus_color = lv_obj_get_style_text_color(obj, LV_PART_MAIN); statusbar_data.mutex.lock(MAX_TICKS); for (int i = 0; i < STATUSBAR_ICON_LIMIT; ++i) { - auto* image = lv_image_create(obj); + auto* image = lv_image_create(icon_row); lv_obj_set_size(image, icon_size, icon_size); // regular padding doesn't work lv_obj_set_style_text_font(image, lvgl_get_statusbar_icon_font(), LV_STATE_DEFAULT); lv_obj_set_style_pad_all(image, 0, LV_STATE_DEFAULT); + if (own_focus_ring) { + lv_obj_set_style_outline_color(image, focus_color, LV_STATE_FOCUS_KEY); + lv_obj_set_style_outline_width(image, focus_width, LV_STATE_FOCUS_KEY); + lv_obj_set_style_outline_pad(image, -focus_width, LV_STATE_FOCUS_KEY); + } + lv_obj_add_event_cb(image, onIconClicked, LV_EVENT_SHORT_CLICKED, reinterpret_cast(static_cast(i))); statusbar->icons[i] = image; update_icon(image, &(statusbar_data.icons[i])); @@ -218,6 +281,15 @@ lv_obj_t* statusbar_create(lv_obj_t* parent) { }); statusbar_data.mutex.unlock(); + // Without a touchscreen, the icons are selected with the keys + if (lvgl_indev_exists(LV_INDEV_TYPE_KEYPAD)) { + lvgl_grid_navigation_add(icon_row); + } + // Only the icon row is focused when it isn't the statusbar itself + if (icon_row != obj && lv_obj_get_group(obj) != nullptr) { + lv_group_remove_obj(obj); + } + return obj; } @@ -287,6 +359,7 @@ void statusbar_icon_remove(int8_t id) { icon->claimed = false; icon->visible = false; icon->image = ""; + icon->appId = ""; statusbar_data.mutex.unlock(); statusbar_data.pubsub->publish(nullptr); } @@ -317,6 +390,16 @@ void statusbar_icon_set_visibility(int8_t id, bool visible) { statusbar_data.pubsub->publish(nullptr); } +void statusbar_icon_set_app(int8_t id, const std::string& appId) { + check(id >= 0 && id < STATUSBAR_ICON_LIMIT); + statusbar_data.mutex.lock(); + StatusbarIcon* icon = &statusbar_data.icons[id]; + check(icon->claimed); + icon->appId = appId; + statusbar_data.mutex.unlock(); + statusbar_data.pubsub->publish(nullptr); +} + int statusbar_get_height() { const auto icon_size = lvgl_get_statusbar_icon_font_height(); const auto vertical_padding = static_cast((static_cast(icon_size) * 0.1f)); diff --git a/Tactility/Source/service/memorychecker/MemoryCheckerService.cpp b/Tactility/Source/service/memorychecker/MemoryCheckerService.cpp index 2c907bac6..d0cbf3a84 100644 --- a/Tactility/Source/service/memorychecker/MemoryCheckerService.cpp +++ b/Tactility/Source/service/memorychecker/MemoryCheckerService.cpp @@ -55,6 +55,7 @@ bool MemoryCheckerService::onStart(ServiceContext& service) { lock.lock(); statusbarIconId = lvgl::statusbar_icon_add(LVGL_ICON_STATUSBAR_MEMORY, false); + lvgl::statusbar_icon_set_app(statusbarIconId, "tactility.systeminfo"); lvgl::statusbar_icon_set_visibility(statusbarIconId, false); timer.setCallbackPriority(Thread::Priority::Lower); diff --git a/Tactility/Source/service/statusbar/Statusbar.cpp b/Tactility/Source/service/statusbar/Statusbar.cpp index d12fe8df5..507f724f5 100644 --- a/Tactility/Source/service/statusbar/Statusbar.cpp +++ b/Tactility/Source/service/statusbar/Statusbar.cpp @@ -85,7 +85,7 @@ static const char* getBluetoothStatusIcon(tt::bluetooth::RadioState state, bool using enum tt::bluetooth::RadioState; case Off: case OffPending: - return nullptr; // hidden when off + return LVGL_ICON_STATUSBAR_BLUETOOTH_DISABLED; case OnPending: case On: if (connected) return LVGL_ICON_STATUSBAR_BLUETOOTH_CONNECTED; @@ -144,7 +144,7 @@ class StatusbarService final : public Service { Mutex mutex; std::unique_ptr updateTimer; int8_t gps_icon_id; - bool gps_last_state = false; + const char* gps_last_icon = nullptr; int8_t bt_icon_id; const char* bt_last_icon = nullptr; int8_t wifi_icon_id; @@ -165,15 +165,20 @@ class StatusbarService final : public Service { } void updateGpsIcon() { - bool show_icon = device_has_active_by_type(&GPS_TYPE); - if (gps_last_state != show_icon) { - if (show_icon) { - lvgl::statusbar_icon_set_image(gps_icon_id, LVGL_ICON_STATUSBAR_LOCATION_ON); + const char* icon = nullptr; + if (device_has_active_by_type(&GPS_TYPE)) { + icon = LVGL_ICON_STATUSBAR_LOCATION_ON; + } else if (device_exists_of_type(&GPS_TYPE)) { + icon = LVGL_ICON_STATUSBAR_LOCATION_OFF; + } + if (gps_last_icon != icon) { + if (icon != nullptr) { + lvgl::statusbar_icon_set_image(gps_icon_id, icon); lvgl::statusbar_icon_set_visibility(gps_icon_id, true); } else { lvgl::statusbar_icon_set_visibility(gps_icon_id, false); } - gps_last_state = show_icon; + gps_last_icon = icon; } } @@ -337,6 +342,12 @@ public: sdcard_icon_id = lvgl::statusbar_icon_add(); wifi_icon_id = lvgl::statusbar_icon_add(); power_icon_id = lvgl::statusbar_icon_add(); + lvgl::statusbar_icon_set_app(gps_icon_id, "tactility.gpssettings"); + lvgl::statusbar_icon_set_app(bt_icon_id, "tactility.btmanage"); + lvgl::statusbar_icon_set_app(usb_icon_id, "tactility.usbsettings"); + lvgl::statusbar_icon_set_app(sdcard_icon_id, "tactility.files"); + lvgl::statusbar_icon_set_app(wifi_icon_id, "tactility.wifimanage"); + lvgl::statusbar_icon_set_app(power_icon_id, "tactility.power"); } ~StatusbarService() override { diff --git a/Tactility/Source/service/webserver/WebServerService.cpp b/Tactility/Source/service/webserver/WebServerService.cpp index d0dd196c0..a13894b05 100644 --- a/Tactility/Source/service/webserver/WebServerService.cpp +++ b/Tactility/Source/service/webserver/WebServerService.cpp @@ -206,6 +206,7 @@ bool WebServerService::onStart(ServiceContext& service) { // Create statusbar icon (hidden initially, shown when server actually starts) statusbarIconId = lvgl::statusbar_icon_add(); + lvgl::statusbar_icon_set_app(statusbarIconId, "tactility.webserversettings"); lvgl::statusbar_icon_set_visibility(statusbarIconId, false); // Load and cache settings once at boot diff --git a/Tactility/Source/settings/TrackballSettings.cpp b/Tactility/Source/settings/TrackballSettings.cpp index 564f7a885..da975039e 100644 --- a/Tactility/Source/settings/TrackballSettings.cpp +++ b/Tactility/Source/settings/TrackballSettings.cpp @@ -26,8 +26,8 @@ constexpr auto* KEY_TRACKBALL_MODE = "trackballMode"; constexpr auto* KEY_ENCODER_SENSITIVITY = "encoderSensitivity"; constexpr auto* KEY_POINTER_SENSITIVITY = "pointerSensitivity"; -constexpr uint8_t MIN_ENCODER_SENSITIVITY = 1; -constexpr uint8_t MAX_ENCODER_SENSITIVITY = 10; +constexpr uint8_t MIN_KEY_SENSITIVITY = 1; +constexpr uint8_t MAX_KEY_SENSITIVITY = 10; constexpr uint8_t MIN_POINTER_SENSITIVITY = 1; constexpr uint8_t MAX_POINTER_SENSITIVITY = 10; @@ -64,13 +64,13 @@ bool load(LvglTrackballSettings& settings) { return s == "1" || s == "true" || s == "True" || s == "TRUE"; }; settings.enabled = (tb_enabled != map.end()) ? isTrueValue(tb_enabled->second) : true; - settings.mode = (tb_mode != map.end() && tb_mode->second == "1") ? LVGL_TRACKBALL_MODE_POINTER : LVGL_TRACKBALL_MODE_ENCODER; + settings.mode = (tb_mode != map.end() && tb_mode->second == "1") ? LVGL_TRACKBALL_MODE_POINTER : LVGL_TRACKBALL_MODE_KEYS; auto default_settings = lvgl_trackball_settings_get_default(); - settings.encoder_sensitivity = (enc_sens != map.end()) ? safeParseUint8(enc_sens->second, default_settings.encoder_sensitivity) : default_settings.encoder_sensitivity; + settings.key_sensitivity = (enc_sens != map.end()) ? safeParseUint8(enc_sens->second, default_settings.key_sensitivity) : default_settings.key_sensitivity; settings.pointer_sensitivity = (ptr_sens != map.end()) ? safeParseUint8(ptr_sens->second, default_settings.pointer_sensitivity) : default_settings.pointer_sensitivity; // Clamp values to valid ranges - settings.encoder_sensitivity = std::clamp(settings.encoder_sensitivity, MIN_ENCODER_SENSITIVITY, MAX_ENCODER_SENSITIVITY); + settings.key_sensitivity = std::clamp(settings.key_sensitivity, MIN_KEY_SENSITIVITY, MAX_KEY_SENSITIVITY); settings.pointer_sensitivity = std::clamp(settings.pointer_sensitivity, MIN_POINTER_SENSITIVITY, MAX_POINTER_SENSITIVITY); return true; @@ -92,7 +92,7 @@ bool save(const LvglTrackballSettings& settings) { std::map map; map[KEY_TRACKBALL_ENABLED] = settings.enabled ? "1" : "0"; map[KEY_TRACKBALL_MODE] = (settings.mode == LVGL_TRACKBALL_MODE_POINTER) ? "1" : "0"; - map[KEY_ENCODER_SENSITIVITY] = std::to_string(std::clamp(settings.encoder_sensitivity, MIN_ENCODER_SENSITIVITY, MAX_ENCODER_SENSITIVITY)); + map[KEY_ENCODER_SENSITIVITY] = std::to_string(std::clamp(settings.key_sensitivity, MIN_KEY_SENSITIVITY, MAX_KEY_SENSITIVITY)); map[KEY_POINTER_SENSITIVITY] = std::to_string(std::clamp(settings.pointer_sensitivity, MIN_POINTER_SENSITIVITY, MAX_POINTER_SENSITIVITY)); auto settings_path = getSettingsFilePath(); if (!file::findOrCreateParentDirectory(settings_path, 0755)) { diff --git a/TactilityKernel/bindings/display.yaml b/TactilityKernel/bindings/display.yaml new file mode 100644 index 000000000..5e6a4f918 --- /dev/null +++ b/TactilityKernel/bindings/display.yaml @@ -0,0 +1,11 @@ +description: Common display properties + +properties: + shape: + type: int + default: DISPLAY_SHAPE_RECTANGLE + description: Shape of the visible area (DISPLAY_SHAPE_RECTANGLE, DISPLAY_SHAPE_ROUNDED or DISPLAY_SHAPE_CIRCLE) + corner-radius: + type: int + default: 0 + description: Corner radius in pixels, for DISPLAY_SHAPE_ROUNDED diff --git a/TactilityKernel/include/tactility/drivers/display.h b/TactilityKernel/include/tactility/drivers/display.h index 1bd2b768d..e692e5a3f 100644 --- a/TactilityKernel/include/tactility/drivers/display.h +++ b/TactilityKernel/include/tactility/drivers/display.h @@ -10,6 +10,27 @@ extern "C" { #include #include +/** + * @brief The shape of the display's visible area. + */ +enum DisplayShapeType { + DISPLAY_SHAPE_RECTANGLE = 0, + /** A rectangle with rounded corners */ + DISPLAY_SHAPE_ROUNDED = 1, + /** A circle with the diameter of the shortest side, centered on the panel */ + DISPLAY_SHAPE_CIRCLE = 2, +}; + +/** + * @brief The visible area of a display. Display bindings that include display.yaml get it from the devicetree. + */ +struct DisplayShape { + /** A DisplayShapeType value */ + uint8_t shape; + /** The corner radius in pixels, for DISPLAY_SHAPE_ROUNDED */ + uint16_t corner_radius; +}; + /** * @brief Optional capabilities of the display. */ @@ -255,6 +276,14 @@ struct DisplayApi { * @return true if all specified capabilities are available for this device instance */ bool (*has_capability)(struct Device* device, uint32_t capability); + + /** + * @brief Gets the shape of the display's visible area. + * @warning Nullable. When null, display_get_shape() reports a rectangle. + * @param[in] device the display device + * @param[out] shape the shape + */ + void (*get_shape)(struct Device* device, struct DisplayShape* shape); }; /** @@ -387,6 +416,13 @@ uint8_t display_get_frame_buffer_count(struct Device* device); */ error_t display_get_backlight(struct Device* device, struct Device** backlight); +/** + * @brief Gets the shape of the display's visible area. + * @param[in] device the display device + * @param[out] shape the shape: a rectangle when the driver doesn't report one + */ +void display_get_shape(struct Device* device, struct DisplayShape* shape); + extern const struct DeviceType DISPLAY_TYPE; #ifdef __cplusplus diff --git a/TactilityKernel/include/tactility/paths.h b/TactilityKernel/include/tactility/paths.h index 0e6e54616..48b20e4c4 100644 --- a/TactilityKernel/include/tactility/paths.h +++ b/TactilityKernel/include/tactility/paths.h @@ -12,6 +12,16 @@ extern "C" { #define FILE_MAX_PATH_LENGTH 255 #define FILE_MAX_PATH_STRING_LENGTH (FILE_MAX_PATH_LENGTH + 1) +/** + * @brief Get the root path of the storage that holds the user data (e.g. the SD card). + * @param[out] out_path buffer to store the path (no trailing "/") + * @param[in] out_path_size size of the output buffer + * @retval ERROR_NOT_FOUND if the configured storage location isn't available (e.g. no SD card) + * @retval ERROR_BUFFER_OVERFLOW if out_path_size is too small + * @retval ERROR_NONE on success + */ +error_t paths_get_data_root_path(char* out_path, size_t out_path_size); + /** * @brief Get the root path for user data. Survives OS upgrades. * @param[out] out_path buffer to store the path (no trailing "/") diff --git a/TactilityKernel/source/drivers/display.cpp b/TactilityKernel/source/drivers/display.cpp index 912d6f408..cf6615cbc 100644 --- a/TactilityKernel/source/drivers/display.cpp +++ b/TactilityKernel/source/drivers/display.cpp @@ -157,6 +157,16 @@ error_t display_get_backlight(Device* device, Device** backlight) { return error; } +void display_get_shape(Device* device, DisplayShape* shape) { + const auto* driver = device_get_driver(device); + const auto* api = DISPLAY_DRIVER_API(driver); + if (api->get_shape == nullptr) { + *shape = { .shape = DISPLAY_SHAPE_RECTANGLE, .corner_radius = 0 }; + return; + } + api->get_shape(device, shape); +} + const struct DeviceType DISPLAY_TYPE { .name = "display" }; diff --git a/TactilityKernel/source/paths.cpp b/TactilityKernel/source/paths.cpp index 1c0379c2e..a89aaa076 100644 --- a/TactilityKernel/source/paths.cpp +++ b/TactilityKernel/source/paths.cpp @@ -8,7 +8,9 @@ #include #include -static error_t paths_get_data_root_path(char* out_path, size_t out_path_size) { +extern "C" { + +error_t paths_get_data_root_path(char* out_path, size_t out_path_size) { #if defined(CONFIG_TT_USER_DATA_LOCATION_INTERNAL) // Registered by platform-esp32/platform-posix's own module start() (see file_system_add()'s // "data" name), rather than a literal here: on POSIX that filesystem's real path is resolved @@ -38,8 +40,6 @@ static error_t paths_get_data_root_path(char* out_path, size_t out_path_size) { #endif } -extern "C" { - error_t paths_get_data_path(char* out_path, size_t out_path_size) { // Sized for a real, realpath()-resolved absolute path (see paths_get_data_root_path()'s // CONFIG_TT_USER_DATA_LOCATION_INTERNAL branch), not just the short literal this used to be. diff --git a/TactilityKernel/source/symbols.c b/TactilityKernel/source/symbols.c index 3ea20c0a4..7481a24f6 100644 --- a/TactilityKernel/source/symbols.c +++ b/TactilityKernel/source/symbols.c @@ -173,6 +173,7 @@ const struct ModuleSymbol KERNEL_SYMBOLS[] = { DEFINE_MODULE_SYMBOL(display_get_frame_buffer), DEFINE_MODULE_SYMBOL(display_get_frame_buffer_count), DEFINE_MODULE_SYMBOL(display_get_backlight), + DEFINE_MODULE_SYMBOL(display_get_shape), DEFINE_MODULE_SYMBOL(DISPLAY_TYPE), // file system DEFINE_MODULE_SYMBOL(file_system_mount), @@ -271,6 +272,7 @@ const struct ModuleSymbol KERNEL_SYMBOLS[] = { DEFINE_MODULE_SYMBOL(led_strip_get_length), DEFINE_MODULE_SYMBOL(LED_STRIP_TYPE), // drivers/paths + DEFINE_MODULE_SYMBOL(paths_get_data_root_path), DEFINE_MODULE_SYMBOL(paths_get_data_path), DEFINE_MODULE_SYMBOL(paths_get_temp_path), // drivers/pointer diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt index e68b63ee0..3d46db621 100644 --- a/Tests/CMakeLists.txt +++ b/Tests/CMakeLists.txt @@ -14,6 +14,7 @@ add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-posix-module/tests ${CMAKE_CURR add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/http-module/tests ${CMAKE_CURRENT_BINARY_DIR}/http-module) add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/binfont-module/tests ${CMAKE_CURRENT_BINARY_DIR}/binfont-module) add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/lodepng-module/tests ${CMAKE_CURRENT_BINARY_DIR}/lodepng-module) +add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/lvgl-module/tests ${CMAKE_CURRENT_BINARY_DIR}/lvgl-module) add_custom_target(build-tests) add_dependencies(build-tests ServiceModuleTests) @@ -27,3 +28,4 @@ add_dependencies(build-tests AppPosixModuleTests) add_dependencies(build-tests HttpModuleTests) add_dependencies(build-tests BinFontModuleTests) add_dependencies(build-tests LodePngModuleTests) +add_dependencies(build-tests LvglModuleTests)