Gridnav and fixes

This commit is contained in:
Ken Van Hoeylandt 2026-10-07 09:00:12 +02:00
parent 60133ddda6
commit 0b5c302aa2
17 changed files with 232 additions and 103 deletions

View File

@ -37,6 +37,8 @@ CONFIG_LV_FS_STDIO_LETTER=65
CONFIG_LV_FS_STDIO_PATH="" CONFIG_LV_FS_STDIO_PATH=""
CONFIG_LV_FS_STDIO_CACHE_SIZE=4096 CONFIG_LV_FS_STDIO_CACHE_SIZE=4096
CONFIG_LV_USE_LODEPNG=n 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 CONFIG_LV_USE_BUILTIN_MALLOC=n
# Routes lv_malloc/realloc/free through Modules/lvgl-module/source/lv_mem_custom.c, which prefers # 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. # PSRAM (falls back to internal RAM automatically) instead of a fixed-size pool or plain malloc.

View File

@ -103,7 +103,7 @@ static error_t start(Device* device) {
error_t error = acquire_button( error_t error = acquire_button(
config->pin_primary, 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, two_button_mode ? (uint32_t)CODEPOINT_ESCAPE : (uint32_t)CODEPOINT_ENTER,
&internal->primary &internal->primary
); );
@ -112,7 +112,7 @@ static error_t start(Device* device) {
return error; 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 (error != ERROR_NONE) {
if (internal->primary.in_use) { if (internal->primary.in_use) {
gpio_descriptor_release(internal->primary.descriptor); gpio_descriptor_release(internal->primary.descriptor);

View File

@ -1,7 +1,7 @@
description: > description: >
GPIO-attached rotary encoder wheel - a 2-phase quadrature encoder (decoded via the ESP32 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 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. button (if present) press/release is translated to the enter key.
compatible: "tactility,gpio-encoder" compatible: "tactility,gpio-encoder"

View File

@ -262,7 +262,7 @@ static void poll_wheel(GpioEncoderInternal* internal) {
int32_t detents = total / internal->pulses_per_detent; int32_t detents = total / internal->pulses_per_detent;
internal->pulse_remainder = 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; int32_t count = detents >= 0 ? detents : -detents;
for (int32_t i = 0; i < count; i++) { for (int32_t i = 0; i < count; i++) {
// A press without its matching release would leave the consumer thinking the key // A press without its matching release would leave the consumer thinking the key

View File

@ -0,0 +1,45 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <lvgl.h>
#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

View File

@ -1,6 +1,7 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
#include <lvgl/devices/keyboard.h> #include <lvgl/devices/keyboard.h>
#include <lvgl/devices/device_context.h> #include <lvgl/devices/device_context.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <tactility/drivers/keyboard.h> #include <tactility/drivers/keyboard.h>
@ -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_UP: return LV_KEY_PREV;
case CODEPOINT_ARROW_RIGHT: return LV_KEY_RIGHT; case CODEPOINT_ARROW_RIGHT: return LV_KEY_RIGHT;
case CODEPOINT_ARROW_DOWN: return LV_KEY_NEXT; 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_HOME: return LV_KEY_HOME;
case CODEPOINT_END: return LV_KEY_END; case CODEPOINT_END: return LV_KEY_END;
default: return key; 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); 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->state = key_data.pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED;
data->continue_reading = key_data.continue_reading; data->continue_reading = key_data.continue_reading;
} }

View File

@ -0,0 +1,107 @@
// SPDX-License-Identifier: Apache-2.0
#include <lvgl/grid_navigation.h>
// 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_obj_t*>(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<int32_t>(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<int32_t>(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_t>(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<int32_t>(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<int32_t>(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);
}

View File

@ -10,6 +10,7 @@
#include <lvgl/devices/pointer.h> #include <lvgl/devices/pointer.h>
#include <lvgl/widgets/sliderbox.h> #include <lvgl/widgets/sliderbox.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/theme.h> #include <lvgl/theme.h>
#include <lvgl/widgets/badge.h> #include <lvgl/widgets/badge.h>
#include <lvgl/widgets/card.h> #include <lvgl/widgets/card.h>
@ -61,6 +62,12 @@ const struct ModuleSymbol lvgl_module_symbols[] = {
DEFINE_MODULE_SYMBOL(lvgl_pointer_add), DEFINE_MODULE_SYMBOL(lvgl_pointer_add),
DEFINE_MODULE_SYMBOL(lvgl_pointer_get_slot_index), DEFINE_MODULE_SYMBOL(lvgl_pointer_get_slot_index),
DEFINE_MODULE_SYMBOL(lvgl_pointer_remove), 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 // lvgl_theme
DEFINE_MODULE_SYMBOL(lvgl_theme_get_settings), DEFINE_MODULE_SYMBOL(lvgl_theme_get_settings),
DEFINE_MODULE_SYMBOL(lvgl_theme_get_default_settings), DEFINE_MODULE_SYMBOL(lvgl_theme_get_default_settings),

View File

@ -362,7 +362,6 @@ typedef struct {
lv_style_t icon_button_tonal; lv_style_t icon_button_tonal;
lv_style_t icon_button_tonal_pressed; lv_style_t icon_button_tonal_pressed;
lv_style_t app_bar; lv_style_t app_bar;
lv_style_t app_bar_actions;
lv_style_t app_bar_button; lv_style_t app_bar_button;
lv_style_t app_bar_button_pressed; lv_style_t app_bar_button_pressed;
lv_style_t app_bar_focus_ring; 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_ver(&theme->styles.app_bar, 0);
lv_style_set_pad_gap(&theme->styles.app_bar, APP_BAR_GAP); 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); style_init_reset(&theme->styles.app_bar_button);
lv_style_set_pad_all(&theme->styles.app_bar_button, 0); 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); 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; return;
} }
#endif #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 */ /* obj */
lv_obj_add_style(obj, &theme->styles.container, 0); lv_obj_add_style(obj, &theme->styles.container, 0);
lv_obj_add_style(obj, &theme->styles.scrollbar, LV_PART_SCROLLBAR); 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); lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED);
return; 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 */ /* icon_button */
lv_obj_add_style(obj, &theme->styles.icon_button, 0); lv_obj_add_style(obj, &theme->styles.icon_button, 0);
lv_obj_add_style(obj, &theme->styles.transition_exit, 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_USE_DROPDOWN
if(lv_obj_check_type(obj, &lv_dropdown_class)) { if(lv_obj_check_type(obj, &lv_dropdown_class)) {
/* toolbar > obj > dropdown */ /* toolbar > dropdown */
if(lv_obj_check_type(lss_ancestor(obj, 2), &lvgl_toolbar_class) && lv_obj_check_type(parent, &lv_obj_class)) { 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.field, 0);
lv_obj_add_style(obj, &theme->styles.transition_exit, 0); lv_obj_add_style(obj, &theme->styles.transition_exit, 0);
lv_obj_add_style(obj, &theme->styles.state_pressed, LV_STATE_PRESSED); lv_obj_add_style(obj, &theme->styles.state_pressed, LV_STATE_PRESSED);

View File

@ -226,7 +226,6 @@ typedef struct {
lv_style_t icon_button_filled_pressed; lv_style_t icon_button_filled_pressed;
lv_style_t icon_button_tonal; lv_style_t icon_button_tonal;
lv_style_t app_bar; lv_style_t app_bar;
lv_style_t app_bar_actions;
lv_style_t app_bar_button; lv_style_t app_bar_button;
lv_style_t app_bar_focus_ring; lv_style_t app_bar_focus_ring;
#if LV_USE_SWITCH #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_ver(&theme->styles.app_bar, 0);
lv_style_set_pad_gap(&theme->styles.app_bar, APP_BAR_GAP); 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); style_init_reset(&theme->styles.app_bar_button);
lv_style_set_pad_all(&theme->styles.app_bar_button, 0); 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; return;
} }
#endif #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 */ /* obj */
lv_obj_add_style(obj, &theme->styles.container, 0); lv_obj_add_style(obj, &theme->styles.container, 0);
lv_obj_add_style(obj, &theme->styles.scrollbar, LV_PART_SCROLLBAR); 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); lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED);
return; 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 */ /* icon_button */
lv_obj_add_style(obj, &theme->styles.icon_button, 0); lv_obj_add_style(obj, &theme->styles.icon_button, 0);
lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_PRESSED); lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_PRESSED);

View File

@ -4,12 +4,16 @@
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
#include <lvgl/fonts.h> #include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/widgets/icon_button.h> #include <lvgl/widgets/icon_button.h>
#include <lvgl/widgets/spinner.h> #include <lvgl/widgets/spinner.h>
#include <tactility/check.h> #include <tactility/check.h>
#include <tactility/drivers/pointer.h> #include <tactility/drivers/pointer.h>
#include <tactility/log.h>
constexpr auto* TAG = "lvgl_toolbar";
static uint32_t getToolbarHeight(UiDensity uiDensity) { static uint32_t getToolbarHeight(UiDensity uiDensity) {
if (uiDensity == LVGL_UI_DENSITY_COMPACT) { if (uiDensity == LVGL_UI_DENSITY_COMPACT) {
@ -39,7 +43,6 @@ typedef struct {
lv_obj_t* title_label; lv_obj_t* title_label;
lv_obj_t* close_button; lv_obj_t* close_button;
lv_obj_t* close_button_image; lv_obj_t* close_button_image;
lv_obj_t* action_container;
uint8_t action_count; uint8_t action_count;
lv_event_cb_t nav_action_callback; lv_event_cb_t nav_action_callback;
} Toolbar; } Toolbar;
@ -99,6 +102,8 @@ lv_obj_t* lvgl_toolbar_create(lv_obj_t* parent, const char* title) {
lv_obj_center(obj); lv_obj_center(obj);
lv_obj_set_flex_flow(obj, LV_FLEX_FLOW_ROW); 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); 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 = create_action_button(obj, ui_density);
toolbar->close_button_image = lv_image_create(toolbar->close_button); 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_label_set_long_mode(toolbar->title_label, LV_LABEL_LONG_MODE_SCROLL);
lv_obj_set_flex_grow(toolbar->title_label, 1); 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); 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 // 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. // an extra time for every screen.
if (!device_has_active_by_type(&POINTER_TYPE)) { 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_obj_update_layout(obj); // Resolve flex layout first, so focus/state invalidate against final coords
lv_group_focus_obj(toolbar->close_button); lv_group_focus_obj(obj);
lv_obj_add_state(toolbar->close_button, LV_STATE_FOCUS_KEY); lv_gridnav_set_focused(obj, toolbar->close_button, LV_ANIM_OFF);
} }
return obj; 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"); check(toolbar->action_count < TOOLBAR_ACTION_LIMIT, "max actions reached");
toolbar->action_count++; 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_add_event_cb(action_button, callback, LV_EVENT_SHORT_CLICKED, user_data);
lv_obj_t* button_content; lv_obj_t* button_content;
if (isImage) { 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"); check(toolbar->action_count < TOOLBAR_ACTION_LIMIT, "max actions reached");
toolbar->action_count++; 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) { 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"); check(toolbar->action_count < TOOLBAR_ACTION_LIMIT, "max actions reached");
toolbar->action_count++; 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_options(widget, options);
lv_dropdown_set_selected_highlight(widget, false); lv_dropdown_set_selected_highlight(widget, false);
if (width > 0) { 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"); check(toolbar->action_count < TOOLBAR_ACTION_LIMIT, "max actions reached");
toolbar->action_count++; 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) { 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); 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) { void lvgl_toolbar_clear_actions(lv_obj_t* obj) {
auto* toolbar = reinterpret_cast<Toolbar*>(obj); auto* toolbar = reinterpret_cast<Toolbar*>(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; toolbar->action_count = 0;
} }

View File

@ -676,14 +676,6 @@ app_bar {
padGap: @app-bar-gap; padGap: @app-bar-gap;
} }
app_bar_actions {
bgOpa: transp;
borderWidth: 0;
radius: 0;
padAll: 0;
padGap: @app-bar-gap;
}
app_bar_button { app_bar_button {
padAll: 0; padAll: 0;
textColor: @app-bar-content-color; textColor: @app-bar-content-color;
@ -1236,10 +1228,6 @@ apply(calendar > obj) {
// No styles // No styles
} }
apply(toolbar > obj) {
app_bar_actions: default;
}
apply(obj) { apply(obj) {
container: default; container: default;
scrollbar: part.scrollbar; scrollbar: part.scrollbar;
@ -1279,7 +1267,7 @@ apply(button) {
button_disabled: state.disabled; button_disabled: state.disabled;
} }
apply(toolbar > icon_button, toolbar > obj > icon_button) { apply(toolbar > icon_button) {
icon_button: default; icon_button: default;
app_bar_button: default; app_bar_button: default;
transition_exit: default; transition_exit: default;
@ -1290,7 +1278,7 @@ apply(toolbar > icon_button, toolbar > obj > icon_button) {
disabled: state.disabled; disabled: state.disabled;
} }
apply(toolbar > obj > dropdown) { apply(toolbar > dropdown) {
@include dropdown; @include dropdown;
app_bar_field: default; app_bar_field: default;
app_bar_focus_ring: state.focusKey; app_bar_focus_ring: state.focusKey;

View File

@ -470,14 +470,6 @@ app_bar {
padGap: @app-bar-gap; padGap: @app-bar-gap;
} }
app_bar_actions {
bgOpa: transp;
borderWidth: 0;
radius: 0;
padAll: 0;
padGap: @app-bar-gap;
}
app_bar_button { app_bar_button {
padAll: 0; padAll: 0;
} }
@ -944,10 +936,6 @@ apply(calendar > obj) {
// No styles // No styles
} }
apply(toolbar > obj) {
app_bar_actions: default;
}
apply(obj) { apply(obj) {
container: default; container: default;
scrollbar: part.scrollbar; scrollbar: part.scrollbar;
@ -963,7 +951,7 @@ apply(tabview > *[0] > button) {
edit_ring: state.edited; edit_ring: state.edited;
} }
apply(toolbar > icon_button, toolbar > obj > icon_button) { apply(toolbar > icon_button) {
icon_button: default; icon_button: default;
app_bar_button: default; app_bar_button: default;
inverted: state.pressed; inverted: state.pressed;

View File

@ -2,6 +2,7 @@
#include <lvgl/devices/indev.h> #include <lvgl/devices/indev.h>
#include <lvgl/fonts.h> #include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/widgets/badge.h> #include <lvgl/widgets/badge.h>
#include <lvgl/widgets/icon_button.h> #include <lvgl/widgets/icon_button.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
@ -104,13 +105,14 @@ void AppGrid::requestRepopulate(bool keepSelection) {
} }
void AppGrid::populate() { void AppGrid::populate() {
// Captured before lv_obj_clean() deletes the currently focused tile below: LVGL moves // Captured before lv_obj_clean() deletes the currently focused tile below: creating
// focus elsewhere as it leaves the group, and creating replacement tiles doesn't restore // replacement tiles doesn't restore the focus, so the app id is remembered here and
// it, so the app id is remembered here and re-focused on its new tile once rebuilt. // re-focused on its new tile once rebuilt.
std::string focusedAppId; std::string focusedAppId;
lv_group_t* group = lv_group_get_default(); lv_group_t* group = lv_group_get_default();
if (group != nullptr && keepSelectionOnRepopulate) { 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) { if (focused != nullptr && lv_obj_get_parent(focused) == grid) {
focusedAppId = static_cast<const AppGridItem*>(lv_obj_get_user_data(focused))->manifest.id; focusedAppId = static_cast<const AppGridItem*>(lv_obj_get_user_data(focused))->manifest.id;
} }
@ -193,8 +195,8 @@ void AppGrid::populate() {
} }
if (focusedTile != nullptr) { if (focusedTile != nullptr) {
lv_group_focus_obj(focusedTile); lv_group_focus_obj(grid);
lv_obj_add_state(focusedTile, LV_STATE_FOCUS_KEY); 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_pad_column(grid, TILE_GAP, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(grid, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(grid, 0, LV_STATE_DEFAULT);
lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE); 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. // Resolves the grid's flex_grow height, which the page size is derived from.
lv_obj_update_layout(parent); lv_obj_update_layout(parent);

View File

@ -9,6 +9,7 @@
#include <lvgl.h> #include <lvgl.h>
#include <lvgl/icons/launcher.h> #include <lvgl/icons/launcher.h>
#include <lvgl/fonts.h> #include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/theme.h> #include <lvgl/theme.h>
#include <lvgl/widgets/icon_button.h> #include <lvgl/widgets/icon_button.h>
@ -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_FOLDER, "tactility.files");
createAppButton(buttons_wrapper, variant, LVGL_ICON_LAUNCHER_SETTINGS, "tactility.settings"); createAppButton(buttons_wrapper, variant, LVGL_ICON_LAUNCHER_SETTINGS, "tactility.settings");
applyButtonMargins(buttons_wrapper, is_landscape_display); 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 // 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 // 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. // an extra time.
if (!device_has_active_by_type(&POINTER_TYPE)) { 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_obj_update_layout(parent); // Resolve flex layout first, so focus/state invalidate against final coords
lv_group_focus_obj(app_list_button); lv_group_focus_obj(buttons_wrapper);
lv_obj_add_state(app_list_button, LV_STATE_FOCUS_KEY); lv_gridnav_set_focused(buttons_wrapper, app_list_button, LV_ANIM_OFF);
} }
} }

View File

@ -30,6 +30,10 @@ typedef enum {
CODEPOINT_ARROW_DOWN = 0x2193, CODEPOINT_ARROW_DOWN = 0x2193,
CODEPOINT_HOME = 0x21F1, CODEPOINT_HOME = 0x21F1,
CODEPOINT_END = 0x21F2, 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; } CodePoint;
/** /**

View File

@ -773,7 +773,7 @@
#define LV_USE_MONKEY 0 #define LV_USE_MONKEY 0
/*1: Enable grid navigation*/ /*1: Enable grid navigation*/
#define LV_USE_GRIDNAV 0 #define LV_USE_GRIDNAV 1
/*1: Enable lv_obj fragment*/ /*1: Enable lv_obj fragment*/
#define LV_USE_FRAGMENT 0 #define LV_USE_FRAGMENT 0