From 0b5c302aa2e4a25a1b196f1328225b288b48ca55 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Wed, 7 Oct 2026 09:00:12 +0200 Subject: [PATCH] Gridnav and fixes --- Buildscripts/sdkconfig-default.properties | 2 + .../source/button_control.cpp | 4 +- .../bindings/tactility,gpio-encoder.yaml | 2 +- .../source/gpio_encoder.cpp | 2 +- .../include/lvgl/grid_navigation.h | 45 ++++++++ .../lvgl-module/source/devices/keyboard.cpp | 22 ++++ .../lvgl-module/source/grid_navigation.cpp | 107 ++++++++++++++++++ Modules/lvgl-module/source/symbols.c | 7 ++ .../source/themes/lv_theme_material.c | 29 +---- .../source/themes/lv_theme_material_mono.c | 23 ---- .../lvgl-module/source/widgets/toolbar.cpp | 33 +++--- Modules/lvgl-module/themes/material.lss | 16 +-- Modules/lvgl-module/themes/material_mono.lss | 14 +-- Tactility/Source/app/AppGrid.cpp | 16 ++- Tactility/Source/app/launcher/Launcher.cpp | 7 +- .../include/tactility/drivers/keyboard.h | 4 + lv_conf.h | 2 +- 17 files changed, 232 insertions(+), 103 deletions(-) create mode 100644 Modules/lvgl-module/include/lvgl/grid_navigation.h create mode 100644 Modules/lvgl-module/source/grid_navigation.cpp diff --git a/Buildscripts/sdkconfig-default.properties b/Buildscripts/sdkconfig-default.properties index 3a6cdf62f..aa5e1c34c 100644 --- a/Buildscripts/sdkconfig-default.properties +++ b/Buildscripts/sdkconfig-default.properties @@ -37,6 +37,8 @@ CONFIG_LV_FS_STDIO_LETTER=65 CONFIG_LV_FS_STDIO_PATH="" CONFIG_LV_FS_STDIO_CACHE_SIZE=4096 CONFIG_LV_USE_LODEPNG=n +# 2D keyboard navigation in grids (lvgl_grid_navigation_add()) +CONFIG_LV_USE_GRIDNAV=y CONFIG_LV_USE_BUILTIN_MALLOC=n # Routes lv_malloc/realloc/free through Modules/lvgl-module/source/lv_mem_custom.c, which prefers # PSRAM (falls back to internal RAM automatically) instead of a fixed-size pool or plain malloc. diff --git a/Drivers/button-control-module/source/button_control.cpp b/Drivers/button-control-module/source/button_control.cpp index 3f6f91da4..868a164c6 100644 --- a/Drivers/button-control-module/source/button_control.cpp +++ b/Drivers/button-control-module/source/button_control.cpp @@ -103,7 +103,7 @@ static error_t start(Device* device) { error_t error = acquire_button( config->pin_primary, - two_button_mode ? (uint32_t)CODEPOINT_ENTER : (uint32_t)CODEPOINT_ARROW_DOWN, + two_button_mode ? (uint32_t)CODEPOINT_ENTER : (uint32_t)CODEPOINT_FOCUS_NEXT, two_button_mode ? (uint32_t)CODEPOINT_ESCAPE : (uint32_t)CODEPOINT_ENTER, &internal->primary ); @@ -112,7 +112,7 @@ static error_t start(Device* device) { return error; } - error = acquire_button(config->pin_secondary, CODEPOINT_ARROW_DOWN, CODEPOINT_ARROW_UP, &internal->secondary); + error = acquire_button(config->pin_secondary, CODEPOINT_FOCUS_NEXT, CODEPOINT_FOCUS_PREVIOUS, &internal->secondary); if (error != ERROR_NONE) { if (internal->primary.in_use) { gpio_descriptor_release(internal->primary.descriptor); diff --git a/Drivers/gpio-encoder-module/bindings/tactility,gpio-encoder.yaml b/Drivers/gpio-encoder-module/bindings/tactility,gpio-encoder.yaml index aa22d3f89..a380748f0 100644 --- a/Drivers/gpio-encoder-module/bindings/tactility,gpio-encoder.yaml +++ b/Drivers/gpio-encoder-module/bindings/tactility,gpio-encoder.yaml @@ -1,7 +1,7 @@ description: > GPIO-attached rotary encoder wheel - a 2-phase quadrature encoder (decoded via the ESP32 hardware PCNT peripheral) plus an optional separate click/enter button. Exposes a - KEYBOARD_TYPE device: each wheel detent is translated to an arrow up/down key, and the + KEYBOARD_TYPE device: each wheel detent is translated to a focus next/previous key, and the button (if present) press/release is translated to the enter key. compatible: "tactility,gpio-encoder" diff --git a/Drivers/gpio-encoder-module/source/gpio_encoder.cpp b/Drivers/gpio-encoder-module/source/gpio_encoder.cpp index 613a20411..ef9594742 100644 --- a/Drivers/gpio-encoder-module/source/gpio_encoder.cpp +++ b/Drivers/gpio-encoder-module/source/gpio_encoder.cpp @@ -262,7 +262,7 @@ static void poll_wheel(GpioEncoderInternal* internal) { int32_t detents = total / internal->pulses_per_detent; internal->pulse_remainder = total % internal->pulses_per_detent; - uint32_t key = detents >= 0 ? CODEPOINT_ARROW_DOWN : CODEPOINT_ARROW_UP; + uint32_t key = detents >= 0 ? CODEPOINT_FOCUS_NEXT : CODEPOINT_FOCUS_PREVIOUS; int32_t count = detents >= 0 ? detents : -detents; for (int32_t i = 0; i < count; i++) { // A press without its matching release would leave the consumer thinking the key diff --git a/Modules/lvgl-module/include/lvgl/grid_navigation.h b/Modules/lvgl-module/include/lvgl/grid_navigation.h new file mode 100644 index 000000000..36bc27797 --- /dev/null +++ b/Modules/lvgl-module/include/lvgl/grid_navigation.h @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Lets the arrow keys move the focus between the container's children by their position, e.g. in a grid of buttons. + * The container takes the place of its children in the focus group. Moving past the first or last row leaves the container. + * @param[in] container the container with the focusable children + */ +void lvgl_grid_navigation_add(lv_obj_t* container); + +/** + * @brief Turns grid navigation off again: the focusable children rejoin the container's focus group. + * @param[in] container a container that was passed to lvgl_grid_navigation_add() + */ +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 + */ +void lvgl_grid_navigation_focus_next(lv_obj_t* container, bool next); + +/** + * @param[in] container a container that was passed to lvgl_grid_navigation_add() + * @return the focused child, or NULL when no child is focused + */ +lv_obj_t* lvgl_grid_navigation_get_focused(lv_obj_t* container); + +/** + * @param[in] obj the object to check + * @return true when the object is a container that was passed to lvgl_grid_navigation_add() + */ +bool lvgl_grid_navigation_is_container(const lv_obj_t* obj); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/lvgl-module/source/devices/keyboard.cpp b/Modules/lvgl-module/source/devices/keyboard.cpp index 96f16bd24..11fd2d09f 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 @@ -55,6 +56,8 @@ 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; @@ -72,6 +75,25 @@ static void lvgl_keyboard_read_cb(lv_indev_t* indev, lv_indev_data_t* data) { } 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; + } + } 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/grid_navigation.cpp b/Modules/lvgl-module/source/grid_navigation.cpp new file mode 100644 index 000000000..59d86afe0 --- /dev/null +++ b/Modules/lvgl-module/source/grid_navigation.cpp @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +// Marks containers with grid navigation, so the keyboard sends them up and down keys +constexpr lv_obj_flag_t GRID_NAVIGATION_FLAG = LV_OBJ_FLAG_USER_1; + +static void remove_from_group(lv_obj_t* child) { + if (lv_obj_get_group(child) != nullptr) { + lv_group_remove_obj(child); + } +} + +// Children join the default group when they're created, but grid navigation focuses them through the container +static void on_child_created(lv_event_t* event) { + auto* child = static_cast(lv_event_get_param(event)); + auto* container = lv_event_get_current_target_obj(event); + if (child != nullptr && lv_obj_get_parent(child) == container) { + remove_from_group(child); + } +} + +void lvgl_grid_navigation_add(lv_obj_t* container) { + lv_gridnav_add(container, LV_GRIDNAV_CTRL_NONE); + lv_obj_add_flag(container, GRID_NAVIGATION_FLAG); + + const uint32_t child_count = lv_obj_get_child_count(container); + for (uint32_t i = 0; i < child_count; i++) { + remove_from_group(lv_obj_get_child(container, static_cast(i))); + } + lv_obj_add_event_cb(container, on_child_created, LV_EVENT_CHILD_CREATED, nullptr); + + auto* group = lv_group_get_default(); + if (group != nullptr && lv_obj_get_group(container) == nullptr) { + lv_group_add_obj(group, container); + } +} + +void lvgl_grid_navigation_remove(lv_obj_t* container) { + if (!lvgl_grid_navigation_is_container(container)) { + return; + } + lv_obj_t* focused_child = lvgl_grid_navigation_get_focused(container); + lv_group_t* group = lv_obj_get_group(container); + const bool container_focused = group != nullptr && lv_group_get_focused(group) == container; + + lv_gridnav_remove(container); + lv_obj_remove_event_cb(container, on_child_created); + lv_obj_remove_flag(container, GRID_NAVIGATION_FLAG); + + if (group == nullptr) { + return; + } + // The children are added at the end of the group + const uint32_t child_count = lv_obj_get_child_count(container); + for (uint32_t i = 0; i < child_count; i++) { + auto* child = lv_obj_get_child(container, static_cast(i)); + if (lv_obj_is_group_def(child) && lv_obj_get_group(child) == nullptr) { + lv_group_add_obj(group, child); + } + } + if (container_focused && focused_child != nullptr) { + lv_group_focus_obj(focused_child); + } +} + +// 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)); +} + +void lvgl_grid_navigation_focus_next(lv_obj_t* container, 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; + } + } + + 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_obj_t* lvgl_grid_navigation_get_focused(lv_obj_t* container) { + // Grid navigation marks the focused child with the focused state + const uint32_t child_count = lv_obj_get_child_count(container); + for (uint32_t i = 0; i < child_count; i++) { + auto* child = lv_obj_get_child(container, static_cast(i)); + if (lv_obj_has_state(child, LV_STATE_FOCUSED)) { + return child; + } + } + return nullptr; +} + +bool lvgl_grid_navigation_is_container(const lv_obj_t* obj) { + return obj != nullptr && lv_obj_has_flag(obj, GRID_NAVIGATION_FLAG); +} diff --git a/Modules/lvgl-module/source/symbols.c b/Modules/lvgl-module/source/symbols.c index e300ae9c0..8edb9d34e 100644 --- a/Modules/lvgl-module/source/symbols.c +++ b/Modules/lvgl-module/source/symbols.c @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -61,6 +62,12 @@ const struct ModuleSymbol lvgl_module_symbols[] = { DEFINE_MODULE_SYMBOL(lvgl_pointer_add), DEFINE_MODULE_SYMBOL(lvgl_pointer_get_slot_index), DEFINE_MODULE_SYMBOL(lvgl_pointer_remove), + // 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_get_focused), + DEFINE_MODULE_SYMBOL(lvgl_grid_navigation_is_container), // 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 162c7f6c8..a5f5f5841 100644 --- a/Modules/lvgl-module/source/themes/lv_theme_material.c +++ b/Modules/lvgl-module/source/themes/lv_theme_material.c @@ -362,7 +362,6 @@ typedef struct { lv_style_t icon_button_tonal; lv_style_t icon_button_tonal_pressed; lv_style_t app_bar; - lv_style_t app_bar_actions; lv_style_t app_bar_button; lv_style_t app_bar_button_pressed; lv_style_t app_bar_focus_ring; @@ -866,13 +865,6 @@ static void style_init(my_theme_t * theme) lv_style_set_pad_ver(&theme->styles.app_bar, 0); lv_style_set_pad_gap(&theme->styles.app_bar, APP_BAR_GAP); - style_init_reset(&theme->styles.app_bar_actions); - lv_style_set_bg_opa(&theme->styles.app_bar_actions, LV_OPA_TRANSP); - lv_style_set_border_width(&theme->styles.app_bar_actions, 0); - lv_style_set_radius(&theme->styles.app_bar_actions, 0); - lv_style_set_pad_all(&theme->styles.app_bar_actions, 0); - lv_style_set_pad_gap(&theme->styles.app_bar_actions, APP_BAR_GAP); - style_init_reset(&theme->styles.app_bar_button); lv_style_set_pad_all(&theme->styles.app_bar_button, 0); lv_style_set_text_color(&theme->styles.app_bar_button, APP_BAR_CONTENT_COLOR); @@ -1617,11 +1609,6 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj) return; } #endif - /* toolbar > obj */ - if(lv_obj_check_type(parent, &lvgl_toolbar_class)) { - lv_obj_add_style(obj, &theme->styles.app_bar_actions, 0); - return; - } /* obj */ lv_obj_add_style(obj, &theme->styles.container, 0); lv_obj_add_style(obj, &theme->styles.scrollbar, LV_PART_SCROLLBAR); @@ -1705,18 +1692,6 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj) lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED); return; } - /* toolbar > obj > icon_button */ - if(lv_obj_check_type(lss_ancestor(obj, 2), &lvgl_toolbar_class) && lv_obj_check_type(parent, &lv_obj_class)) { - lv_obj_add_style(obj, &theme->styles.icon_button, 0); - lv_obj_add_style(obj, &theme->styles.app_bar_button, 0); - lv_obj_add_style(obj, &theme->styles.transition_exit, 0); - lv_obj_add_style(obj, &theme->styles.app_bar_button_pressed, LV_STATE_PRESSED); - lv_obj_add_style(obj, &theme->styles.transition_enter, LV_STATE_PRESSED); - lv_obj_add_style(obj, &theme->styles.app_bar_focus_ring, LV_STATE_FOCUS_KEY); - lv_obj_add_style(obj, &theme->styles.app_bar_focus_ring, LV_STATE_EDITED); - lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED); - return; - } /* icon_button */ lv_obj_add_style(obj, &theme->styles.icon_button, 0); lv_obj_add_style(obj, &theme->styles.transition_exit, 0); @@ -1729,8 +1704,8 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj) } #if LV_USE_DROPDOWN if(lv_obj_check_type(obj, &lv_dropdown_class)) { - /* toolbar > obj > dropdown */ - if(lv_obj_check_type(lss_ancestor(obj, 2), &lvgl_toolbar_class) && lv_obj_check_type(parent, &lv_obj_class)) { + /* toolbar > dropdown */ + if(lv_obj_check_type(parent, &lvgl_toolbar_class)) { lv_obj_add_style(obj, &theme->styles.field, 0); lv_obj_add_style(obj, &theme->styles.transition_exit, 0); lv_obj_add_style(obj, &theme->styles.state_pressed, LV_STATE_PRESSED); 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 75ed66609..143c46c4d 100644 --- a/Modules/lvgl-module/source/themes/lv_theme_material_mono.c +++ b/Modules/lvgl-module/source/themes/lv_theme_material_mono.c @@ -226,7 +226,6 @@ typedef struct { lv_style_t icon_button_filled_pressed; lv_style_t icon_button_tonal; lv_style_t app_bar; - lv_style_t app_bar_actions; lv_style_t app_bar_button; lv_style_t app_bar_focus_ring; #if LV_USE_SWITCH @@ -620,13 +619,6 @@ static void style_init(my_theme_t * theme) lv_style_set_pad_ver(&theme->styles.app_bar, 0); lv_style_set_pad_gap(&theme->styles.app_bar, APP_BAR_GAP); - style_init_reset(&theme->styles.app_bar_actions); - lv_style_set_bg_opa(&theme->styles.app_bar_actions, LV_OPA_TRANSP); - lv_style_set_border_width(&theme->styles.app_bar_actions, 0); - lv_style_set_radius(&theme->styles.app_bar_actions, 0); - lv_style_set_pad_all(&theme->styles.app_bar_actions, 0); - lv_style_set_pad_gap(&theme->styles.app_bar_actions, APP_BAR_GAP); - style_init_reset(&theme->styles.app_bar_button); lv_style_set_pad_all(&theme->styles.app_bar_button, 0); @@ -1268,11 +1260,6 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj) return; } #endif - /* toolbar > obj */ - if(lv_obj_check_type(parent, &lvgl_toolbar_class)) { - lv_obj_add_style(obj, &theme->styles.app_bar_actions, 0); - return; - } /* obj */ lv_obj_add_style(obj, &theme->styles.container, 0); lv_obj_add_style(obj, &theme->styles.scrollbar, LV_PART_SCROLLBAR); @@ -1339,16 +1326,6 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj) lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED); return; } - /* toolbar > obj > icon_button */ - if(lv_obj_check_type(lss_ancestor(obj, 2), &lvgl_toolbar_class) && lv_obj_check_type(parent, &lv_obj_class)) { - lv_obj_add_style(obj, &theme->styles.icon_button, 0); - lv_obj_add_style(obj, &theme->styles.app_bar_button, 0); - lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_PRESSED); - lv_obj_add_style(obj, &theme->styles.app_bar_focus_ring, LV_STATE_FOCUS_KEY); - lv_obj_add_style(obj, &theme->styles.app_bar_focus_ring, LV_STATE_EDITED); - lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED); - return; - } /* icon_button */ lv_obj_add_style(obj, &theme->styles.icon_button, 0); lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_PRESSED); diff --git a/Modules/lvgl-module/source/widgets/toolbar.cpp b/Modules/lvgl-module/source/widgets/toolbar.cpp index b03be279a..9d31934c6 100644 --- a/Modules/lvgl-module/source/widgets/toolbar.cpp +++ b/Modules/lvgl-module/source/widgets/toolbar.cpp @@ -4,12 +4,16 @@ #include #include +#include #include #include #include #include #include +#include + +constexpr auto* TAG = "lvgl_toolbar"; static uint32_t getToolbarHeight(UiDensity uiDensity) { if (uiDensity == LVGL_UI_DENSITY_COMPACT) { @@ -39,7 +43,6 @@ typedef struct { lv_obj_t* title_label; lv_obj_t* close_button; lv_obj_t* close_button_image; - lv_obj_t* action_container; uint8_t action_count; lv_event_cb_t nav_action_callback; } Toolbar; @@ -99,6 +102,8 @@ lv_obj_t* lvgl_toolbar_create(lv_obj_t* parent, const char* title) { lv_obj_center(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 + lvgl_grid_navigation_add(obj); toolbar->close_button = create_action_button(obj, ui_density); toolbar->close_button_image = lv_image_create(toolbar->close_button); @@ -110,11 +115,6 @@ lv_obj_t* lvgl_toolbar_create(lv_obj_t* parent, const char* title) { lv_label_set_long_mode(toolbar->title_label, LV_LABEL_LONG_MODE_SCROLL); lv_obj_set_flex_grow(toolbar->title_label, 1); - toolbar->action_container = lv_obj_create(obj); - lv_obj_set_size(toolbar->action_container, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_set_flex_flow(toolbar->action_container, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(toolbar->action_container, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lvgl_toolbar_set_nav_action(obj, LV_SYMBOL_CLOSE, &default_nav_action, nullptr); // 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 @@ -122,8 +122,8 @@ lv_obj_t* lvgl_toolbar_create(lv_obj_t* parent, const char* title) { // an extra time for every screen. if (!device_has_active_by_type(&POINTER_TYPE)) { lv_obj_update_layout(obj); // Resolve flex layout first, so focus/state invalidate against final coords - lv_group_focus_obj(toolbar->close_button); - lv_obj_add_state(toolbar->close_button, LV_STATE_FOCUS_KEY); + lv_group_focus_obj(obj); + lv_gridnav_set_focused(obj, toolbar->close_button, LV_ANIM_OFF); } return obj; @@ -151,7 +151,7 @@ static lv_obj_t* toolbar_add_button_action(lv_obj_t* obj, const char* imageOrBut check(toolbar->action_count < TOOLBAR_ACTION_LIMIT, "max actions reached"); toolbar->action_count++; - lv_obj_t* action_button = create_action_button(toolbar->action_container, lvgl_get_ui_density()); + lv_obj_t* action_button = create_action_button(obj, lvgl_get_ui_density()); lv_obj_add_event_cb(action_button, callback, LV_EVENT_SHORT_CLICKED, user_data); lv_obj_t* button_content; if (isImage) { @@ -179,7 +179,7 @@ lv_obj_t* lvgl_toolbar_add_switch_action(lv_obj_t* obj) { check(toolbar->action_count < TOOLBAR_ACTION_LIMIT, "max actions reached"); toolbar->action_count++; - return lv_switch_create(toolbar->action_container); + return lv_switch_create(obj); } lv_obj_t* lvgl_toolbar_add_dropdown_action(lv_obj_t* obj, const char* options, lv_coord_t width, const char* text) { @@ -187,7 +187,11 @@ lv_obj_t* lvgl_toolbar_add_dropdown_action(lv_obj_t* obj, const char* options, l check(toolbar->action_count < TOOLBAR_ACTION_LIMIT, "max actions reached"); toolbar->action_count++; - lv_obj_t* widget = lv_dropdown_create(toolbar->action_container); + // The arrow keys can't both navigate the toolbar and the opened dropdown + LOG_W(TAG, "Dropdown actions are deprecated: the toolbar can't be navigated with the arrow keys when it has one"); + lvgl_grid_navigation_remove(obj); + + lv_obj_t* widget = lv_dropdown_create(obj); lv_dropdown_set_options(widget, options); lv_dropdown_set_selected_highlight(widget, false); if (width > 0) { @@ -205,7 +209,7 @@ lv_obj_t* lvgl_toolbar_add_spinner_action(lv_obj_t* obj) { check(toolbar->action_count < TOOLBAR_ACTION_LIMIT, "max actions reached"); toolbar->action_count++; - auto* spinner = lvgl_spinner_create(toolbar->action_container); + auto* spinner = lvgl_spinner_create(obj); if (lv_display_get_color_format(lv_obj_get_display(obj)) == LV_COLOR_FORMAT_L8) { lv_obj_set_style_image_recolor(spinner, lv_theme_get_color_secondary(obj), LV_STATE_DEFAULT); @@ -217,6 +221,9 @@ lv_obj_t* lvgl_toolbar_add_spinner_action(lv_obj_t* obj) { void lvgl_toolbar_clear_actions(lv_obj_t* obj) { auto* toolbar = reinterpret_cast(obj); - lv_obj_clean(toolbar->action_container); + // The actions follow the close button and the title + while (lv_obj_get_child_count(obj) > 2) { + lv_obj_delete(lv_obj_get_child(obj, 2)); + } toolbar->action_count = 0; } diff --git a/Modules/lvgl-module/themes/material.lss b/Modules/lvgl-module/themes/material.lss index 880d3a70e..e95ab34c3 100644 --- a/Modules/lvgl-module/themes/material.lss +++ b/Modules/lvgl-module/themes/material.lss @@ -676,14 +676,6 @@ app_bar { padGap: @app-bar-gap; } -app_bar_actions { - bgOpa: transp; - borderWidth: 0; - radius: 0; - padAll: 0; - padGap: @app-bar-gap; -} - app_bar_button { padAll: 0; textColor: @app-bar-content-color; @@ -1236,10 +1228,6 @@ apply(calendar > obj) { // No styles } -apply(toolbar > obj) { - app_bar_actions: default; -} - apply(obj) { container: default; scrollbar: part.scrollbar; @@ -1279,7 +1267,7 @@ apply(button) { button_disabled: state.disabled; } -apply(toolbar > icon_button, toolbar > obj > icon_button) { +apply(toolbar > icon_button) { icon_button: default; app_bar_button: default; transition_exit: default; @@ -1290,7 +1278,7 @@ apply(toolbar > icon_button, toolbar > obj > icon_button) { disabled: state.disabled; } -apply(toolbar > obj > dropdown) { +apply(toolbar > dropdown) { @include dropdown; app_bar_field: default; app_bar_focus_ring: state.focusKey; diff --git a/Modules/lvgl-module/themes/material_mono.lss b/Modules/lvgl-module/themes/material_mono.lss index 00f640cf0..966f45b0d 100644 --- a/Modules/lvgl-module/themes/material_mono.lss +++ b/Modules/lvgl-module/themes/material_mono.lss @@ -470,14 +470,6 @@ app_bar { padGap: @app-bar-gap; } -app_bar_actions { - bgOpa: transp; - borderWidth: 0; - radius: 0; - padAll: 0; - padGap: @app-bar-gap; -} - app_bar_button { padAll: 0; } @@ -944,10 +936,6 @@ apply(calendar > obj) { // No styles } -apply(toolbar > obj) { - app_bar_actions: default; -} - apply(obj) { container: default; scrollbar: part.scrollbar; @@ -963,7 +951,7 @@ apply(tabview > *[0] > button) { edit_ring: state.edited; } -apply(toolbar > icon_button, toolbar > obj > icon_button) { +apply(toolbar > icon_button) { icon_button: default; app_bar_button: default; inverted: state.pressed; diff --git a/Tactility/Source/app/AppGrid.cpp b/Tactility/Source/app/AppGrid.cpp index 935302b95..bbe303a69 100644 --- a/Tactility/Source/app/AppGrid.cpp +++ b/Tactility/Source/app/AppGrid.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -104,13 +105,14 @@ void AppGrid::requestRepopulate(bool keepSelection) { } void AppGrid::populate() { - // Captured before lv_obj_clean() deletes the currently focused tile below: LVGL moves - // focus elsewhere as it leaves the group, and creating replacement tiles doesn't restore - // it, so the app id is remembered here and re-focused on its new tile once rebuilt. + // Captured before lv_obj_clean() deletes the currently focused tile below: creating + // replacement tiles doesn't restore the focus, so the app id is remembered here and + // re-focused on its new tile once rebuilt. std::string focusedAppId; lv_group_t* group = lv_group_get_default(); if (group != nullptr && keepSelectionOnRepopulate) { - lv_obj_t* focused = lv_group_get_focused(group); + // Grid navigation focuses the grid in the group, and a tile within the grid + lv_obj_t* focused = lv_group_get_focused(group) == grid ? lvgl_grid_navigation_get_focused(grid) : nullptr; if (focused != nullptr && lv_obj_get_parent(focused) == grid) { focusedAppId = static_cast(lv_obj_get_user_data(focused))->manifest.id; } @@ -193,8 +195,8 @@ void AppGrid::populate() { } if (focusedTile != nullptr) { - lv_group_focus_obj(focusedTile); - lv_obj_add_state(focusedTile, LV_STATE_FOCUS_KEY); + lv_group_focus_obj(grid); + lv_gridnav_set_focused(grid, focusedTile, LV_ANIM_OFF); } } @@ -211,6 +213,8 @@ void AppGrid::createWidgets(lv_obj_t* parent, lv_obj_t* toolbar) { lv_obj_set_style_pad_column(grid, TILE_GAP, LV_STATE_DEFAULT); lv_obj_set_style_border_width(grid, 0, LV_STATE_DEFAULT); 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); // Resolves the grid's flex_grow height, which the page size is derived from. lv_obj_update_layout(parent); diff --git a/Tactility/Source/app/launcher/Launcher.cpp b/Tactility/Source/app/launcher/Launcher.cpp index 642008025..7508afc72 100644 --- a/Tactility/Source/app/launcher/Launcher.cpp +++ b/Tactility/Source/app/launcher/Launcher.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -158,6 +159,8 @@ void createWidgets(lv_obj_t* parent, void*) { 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 @@ -182,8 +185,8 @@ void createWidgets(lv_obj_t* parent, void*) { // 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(app_list_button); - lv_obj_add_state(app_list_button, LV_STATE_FOCUS_KEY); + lv_group_focus_obj(buttons_wrapper); + lv_gridnav_set_focused(buttons_wrapper, app_list_button, LV_ANIM_OFF); } } diff --git a/TactilityKernel/include/tactility/drivers/keyboard.h b/TactilityKernel/include/tactility/drivers/keyboard.h index 9f9e1b127..98578392d 100644 --- a/TactilityKernel/include/tactility/drivers/keyboard.h +++ b/TactilityKernel/include/tactility/drivers/keyboard.h @@ -30,6 +30,10 @@ typedef enum { CODEPOINT_ARROW_DOWN = 0x2193, CODEPOINT_HOME = 0x21F1, CODEPOINT_END = 0x21F2, + /** Moves the focus to the next widget, for devices that can only step through widgets (e.g. an encoder wheel) */ + CODEPOINT_FOCUS_NEXT = 0x21E5, + /** Moves the focus to the previous widget, for devices that can only step through widgets (e.g. an encoder wheel) */ + CODEPOINT_FOCUS_PREVIOUS = 0x21E4, } CodePoint; /** diff --git a/lv_conf.h b/lv_conf.h index 5a84bc371..3496f96d0 100644 --- a/lv_conf.h +++ b/lv_conf.h @@ -773,7 +773,7 @@ #define LV_USE_MONKEY 0 /*1: Enable grid navigation*/ -#define LV_USE_GRIDNAV 0 +#define LV_USE_GRIDNAV 1 /*1: Enable lv_obj fragment*/ #define LV_USE_FRAGMENT 0