Launcher improvements

This commit is contained in:
Ken Van Hoeylandt 2026-10-07 22:15:13 +02:00
parent b088c35449
commit a6b880e33f
15 changed files with 596 additions and 109 deletions

View File

@ -28,6 +28,13 @@ void lvgl_grid_navigation_remove(lv_obj_t* container);
*/ */
void lvgl_grid_navigation_step(lv_group_t* group, bool next); void lvgl_grid_navigation_step(lv_group_t* group, bool next);
/**
* @brief Hides the selection that keys made, e.g. when the touchscreen is used. The focus stays where it is,
* and the next key that moves the focus shows the selection again.
* @param[in] group the focus group
*/
void lvgl_focus_hide_key_selection(lv_group_t* group);
/** /**
* @param[in] container a container that was passed to lvgl_grid_navigation_add() * @param[in] container a container that was passed to lvgl_grid_navigation_add()
* @return the focused child, or NULL when no child is focused * @return the focused child, or NULL when no child is focused

View File

@ -0,0 +1,37 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <lvgl.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Creates a page indicator: a row of dots with one dot per page, where the dot of the current page is filled.
* The theme determines the size and colour of the dots.
* When the dots are wider than the indicator's maximum width (lv_obj_set_style_max_width()), it shows the page number instead, e.g. "2/12".
* @param[in] parent the parent object
* @return the created page indicator
*/
lv_obj_t* lvgl_page_indicator_create(lv_obj_t* parent);
/**
* @brief Sets the number of pages, which is the number of dots.
* @param[in] obj the page indicator
* @param[in] count the number of pages
*/
void lvgl_page_indicator_set_page_count(lv_obj_t* obj, uint32_t count);
/**
* @brief Sets the current page, whose dot is filled.
* @param[in] obj the page indicator
* @param[in] page the zero-based index of the current page
*/
void lvgl_page_indicator_set_page(lv_obj_t* obj, uint32_t page);
#ifdef __cplusplus
}
#endif

View File

@ -1,6 +1,7 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
#include <lvgl/devices/pointer.h> #include <lvgl/devices/pointer.h>
#include <lvgl/devices/device_context.h> #include <lvgl/devices/device_context.h>
#include <lvgl/grid_navigation.h>
#include <tactility/device.h> #include <tactility/device.h>
#include <tactility/drivers/pointer.h> #include <tactility/drivers/pointer.h>
@ -140,8 +141,8 @@ static void lvgl_pointer_pool_refresh(struct LvglPointerPool* pool, int32_t nati
// the touch controller reports points in a different order (no touch-ID/tracking field exists // the touch controller reports points in a different order (no touch-ID/tracking field exists
// anywhere in this stack - see esp_lcd_touch_get_coordinates()/PointerApi.get_touched_points()). // anywhere in this stack - see esp_lcd_touch_get_coordinates()/PointerApi.get_touched_points()).
// Unmatched raw points (new touches) claim the nearest inactive slot. Slots with no matching // Unmatched raw points (new touches) claim the nearest inactive slot. Slots with no matching
// point this round go inactive (RELEASED). // point this round go inactive (RELEASED). Returns true when a new touch started.
static void lvgl_pointer_pool_assign(struct LvglPointerPool* pool, int32_t native_x_max) { static bool lvgl_pointer_pool_assign(struct LvglPointerPool* pool, int32_t native_x_max) {
// Touch drivers clamp raw coordinates to the panel's configured native resolution regardless // Touch drivers clamp raw coordinates to the panel's configured native resolution regardless
// of calibration, so native_x_max is a valid scale reference for the distance cap even when calibration is disabled. // of calibration, so native_x_max is a valid scale reference for the distance cap even when calibration is disabled.
// Falls back to a conservative fixed pixel value if the display/resolution isn't available for some reason. // Falls back to a conservative fixed pixel value if the display/resolution isn't available for some reason.
@ -182,6 +183,7 @@ static void lvgl_pointer_pool_assign(struct LvglPointerPool* pool, int32_t nativ
// Second pass: any unclaimed raw point is a new touch - hand it to the first inactive slot // Second pass: any unclaimed raw point is a new touch - hand it to the first inactive slot
// that wasn't just released this round. // that wasn't just released this round.
bool touch_started = false;
for (uint8_t r = 0; r < pool->raw_count; r++) { for (uint8_t r = 0; r < pool->raw_count; r++) {
if (raw_claimed[r]) continue; if (raw_claimed[r]) continue;
for (uint8_t s = 0; s < pool->slot_count; s++) { for (uint8_t s = 0; s < pool->slot_count; s++) {
@ -190,9 +192,11 @@ static void lvgl_pointer_pool_assign(struct LvglPointerPool* pool, int32_t nativ
pool->slot_point[s].x = (lv_coord_t)pool->raw_x[r]; pool->slot_point[s].x = (lv_coord_t)pool->raw_x[r];
pool->slot_point[s].y = (lv_coord_t)pool->raw_y[r]; pool->slot_point[s].y = (lv_coord_t)pool->raw_y[r];
raw_claimed[r] = true; raw_claimed[r] = true;
touch_started = true;
break; break;
} }
} }
return touch_started;
} }
// The actual LVGL indev read callback, shared by every slot in the pool. Only the first slot to // The actual LVGL indev read callback, shared by every slot in the pool. Only the first slot to
@ -219,7 +223,11 @@ static void lvgl_pointer_read_cb(lv_indev_t* indev, lv_indev_data_t* data) {
int32_t native_x_max = display != NULL ? lv_display_get_original_horizontal_resolution(display) - 1 : 0; int32_t native_x_max = display != NULL ? lv_display_get_original_horizontal_resolution(display) - 1 : 0;
int32_t native_y_max = display != NULL ? lv_display_get_original_vertical_resolution(display) - 1 : 0; int32_t native_y_max = display != NULL ? lv_display_get_original_vertical_resolution(display) - 1 : 0;
lvgl_pointer_pool_refresh(pool, native_x_max, native_y_max); lvgl_pointer_pool_refresh(pool, native_x_max, native_y_max);
lvgl_pointer_pool_assign(pool, native_x_max); // A touch hides the selection that keys made, so it doesn't look like two widgets are selected.
// This runs before LVGL handles the press, so a widget that the touch focuses isn't affected.
if (lvgl_pointer_pool_assign(pool, native_x_max) && lv_group_get_default() != NULL) {
lvgl_focus_hide_key_selection(lv_group_get_default());
}
} }
pool->round_pos = (uint8_t)((pool->round_pos + 1) % pool->slot_count); pool->round_pos = (uint8_t)((pool->round_pos + 1) % pool->slot_count);

View File

@ -3,6 +3,7 @@
#include <lvgl/devices/device_context.h> #include <lvgl/devices/device_context.h>
#include <lvgl/devices/keyboard.h> #include <lvgl/devices/keyboard.h>
#include <lvgl/devices/keyboard_private.h> #include <lvgl/devices/keyboard_private.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <tactility/drivers/keyboard.h> #include <tactility/drivers/keyboard.h>
@ -25,6 +26,8 @@ struct LvglTrackballCtx {
int32_t pending_count; int32_t pending_count;
// Keys mode: the LVGL key that is reported as pressed, or 0 // Keys mode: the LVGL key that is reported as pressed, or 0
uint32_t pressed_key; uint32_t pressed_key;
// Pointer mode: whether the button was pressed during the previous read
bool pointer_pressed;
}; };
static inline int32_t clamp(int32_t value, int32_t min_value, int32_t max_value) { static inline int32_t clamp(int32_t value, int32_t min_value, int32_t max_value) {
@ -153,6 +156,12 @@ static void lvgl_trackball_read_cb(lv_indev_t* indev, lv_indev_data_t* data) {
data->point.y = static_cast<int16_t>(ctx->cursor_y); data->point.y = static_cast<int16_t>(ctx->cursor_y);
data->state = pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; data->state = pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED;
// Like a touch, a click hides the selection that keys made
if (pressed && !ctx->pointer_pressed && lv_group_get_default() != nullptr) {
lvgl_focus_hide_key_selection(lv_group_get_default());
}
ctx->pointer_pressed = pressed;
if (pressed) { if (pressed) {
lv_display_trigger_activity(display); lv_display_trigger_activity(display);
} }

View File

@ -110,6 +110,22 @@ void lvgl_grid_navigation_step(lv_group_t* group, bool next) {
} }
} }
void lvgl_focus_hide_key_selection(lv_group_t* group) {
lv_obj_t* focused = lv_group_get_focused(group);
if (focused == nullptr) {
return;
}
lv_group_set_editing(group, false);
constexpr auto key_states = static_cast<lv_state_t>(LV_STATE_FOCUS_KEY | LV_STATE_EDITED);
lv_obj_remove_state(focused, key_states);
if (lvgl_grid_navigation_is_container(focused)) {
lv_obj_t* child = lvgl_grid_navigation_get_focused(focused);
if (child != nullptr) {
lv_obj_remove_state(child, key_states);
}
}
}
lv_obj_t* lvgl_grid_navigation_get_focused(lv_obj_t* container) { lv_obj_t* lvgl_grid_navigation_get_focused(lv_obj_t* container) {
// Grid navigation marks the focused child with the focused state // Grid navigation marks the focused child with the focused state
const uint32_t child_count = lv_obj_get_child_count(container); const uint32_t child_count = lv_obj_get_child_count(container);

View File

@ -17,6 +17,7 @@
#include <lvgl/widgets/card.h> #include <lvgl/widgets/card.h>
#include <lvgl/widgets/chip.h> #include <lvgl/widgets/chip.h>
#include <lvgl/widgets/icon_button.h> #include <lvgl/widgets/icon_button.h>
#include <lvgl/widgets/page_indicator.h>
#include <lvgl/widgets/spinner.h> #include <lvgl/widgets/spinner.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
@ -69,6 +70,7 @@ const struct ModuleSymbol lvgl_module_symbols[] = {
DEFINE_MODULE_SYMBOL(lvgl_grid_navigation_step), DEFINE_MODULE_SYMBOL(lvgl_grid_navigation_step),
DEFINE_MODULE_SYMBOL(lvgl_grid_navigation_get_focused), DEFINE_MODULE_SYMBOL(lvgl_grid_navigation_get_focused),
DEFINE_MODULE_SYMBOL(lvgl_grid_navigation_is_container), DEFINE_MODULE_SYMBOL(lvgl_grid_navigation_is_container),
DEFINE_MODULE_SYMBOL(lvgl_focus_hide_key_selection),
// lvgl_insets // lvgl_insets
DEFINE_MODULE_SYMBOL(lvgl_display_get_shape), DEFINE_MODULE_SYMBOL(lvgl_display_get_shape),
DEFINE_MODULE_SYMBOL(lvgl_display_get_insets), DEFINE_MODULE_SYMBOL(lvgl_display_get_insets),
@ -81,6 +83,9 @@ const struct ModuleSymbol lvgl_module_symbols[] = {
DEFINE_MODULE_SYMBOL(lvgl_theme_is_mono), DEFINE_MODULE_SYMBOL(lvgl_theme_is_mono),
// lvgl_badge // lvgl_badge
DEFINE_MODULE_SYMBOL(lvgl_badge_create), DEFINE_MODULE_SYMBOL(lvgl_badge_create),
DEFINE_MODULE_SYMBOL(lvgl_page_indicator_create),
DEFINE_MODULE_SYMBOL(lvgl_page_indicator_set_page_count),
DEFINE_MODULE_SYMBOL(lvgl_page_indicator_set_page),
// lvgl_card // lvgl_card
DEFINE_MODULE_SYMBOL(lvgl_card_create), DEFINE_MODULE_SYMBOL(lvgl_card_create),
// lvgl_chip // lvgl_chip

View File

@ -16,6 +16,8 @@ extern const lv_obj_class_t lvgl_sliderbox_class;
extern const lv_obj_class_t lvgl_card_class; extern const lv_obj_class_t lvgl_card_class;
extern const lv_obj_class_t lvgl_chip_class; extern const lv_obj_class_t lvgl_chip_class;
extern const lv_obj_class_t lvgl_badge_class; extern const lv_obj_class_t lvgl_badge_class;
extern const lv_obj_class_t lvgl_page_indicator_class;
extern const lv_obj_class_t lvgl_page_indicator_dot_class;
#define NEUTRAL_4 lv_color_hex(0x0E0E0E) #define NEUTRAL_4 lv_color_hex(0x0E0E0E)
#define NEUTRAL_6 lv_color_hex(0x131313) #define NEUTRAL_6 lv_color_hex(0x131313)
@ -137,6 +139,9 @@ extern const lv_obj_class_t lvgl_badge_class;
#define CHIP_SHAPE (theme->config.is_compact ? CORNER_COMPACT : CORNER_SMALL) #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_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 CHIP_PAD_VER (theme->config.is_compact ? 2 : LV_DPX_CALC(theme->disp_dpi, 6))
#define PAGE_INDICATOR_COLOR COLOR_ON_SURFACE_VARIANT
#define PAGE_INDICATOR_DOT_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6))
#define PAGE_INDICATOR_GAP (theme->config.is_compact ? 3 : LV_DPX_CALC(theme->disp_dpi, 6))
#define BADGE_COLOR COLOR_SECONDARY #define BADGE_COLOR COLOR_SECONDARY
#define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) #define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6))
#define SCROLLBAR_COLOR COLOR_OUTLINE #define SCROLLBAR_COLOR COLOR_OUTLINE
@ -334,6 +339,12 @@ typedef struct {
lv_style_t screen; lv_style_t screen;
lv_style_t container; lv_style_t container;
lv_style_t card; lv_style_t card;
lv_style_t page_indicator;
#if LV_USE_LABEL
lv_style_t page_indicator_label;
#endif
lv_style_t page_indicator_dot;
lv_style_t page_indicator_dot_current;
lv_style_t badge; lv_style_t badge;
lv_style_t chip; lv_style_t chip;
lv_style_t chip_selected; lv_style_t chip_selected;
@ -758,6 +769,29 @@ static void style_init(my_theme_t * theme)
lv_style_set_pad_all(&theme->styles.card, CARD_PAD); lv_style_set_pad_all(&theme->styles.card, CARD_PAD);
lv_style_set_pad_gap(&theme->styles.card, CARD_GAP); lv_style_set_pad_gap(&theme->styles.card, CARD_GAP);
style_init_reset(&theme->styles.page_indicator);
lv_style_set_bg_opa(&theme->styles.page_indicator, LV_OPA_TRANSP);
lv_style_set_border_width(&theme->styles.page_indicator, 0);
lv_style_set_pad_all(&theme->styles.page_indicator, 0);
lv_style_set_pad_gap(&theme->styles.page_indicator, PAGE_INDICATOR_GAP);
#if LV_USE_LABEL
style_init_reset(&theme->styles.page_indicator_label);
lv_style_set_text_color(&theme->styles.page_indicator_label, PAGE_INDICATOR_COLOR);
#endif
style_init_reset(&theme->styles.page_indicator_dot);
lv_style_set_width(&theme->styles.page_indicator_dot, PAGE_INDICATOR_DOT_SIZE);
lv_style_set_height(&theme->styles.page_indicator_dot, PAGE_INDICATOR_DOT_SIZE);
lv_style_set_radius(&theme->styles.page_indicator_dot, LV_RADIUS_CIRCLE);
lv_style_set_bg_color(&theme->styles.page_indicator_dot, PAGE_INDICATOR_COLOR);
lv_style_set_bg_opa(&theme->styles.page_indicator_dot, LV_OPA_TRANSP);
lv_style_set_border_color(&theme->styles.page_indicator_dot, PAGE_INDICATOR_COLOR);
lv_style_set_border_width(&theme->styles.page_indicator_dot, 1);
style_init_reset(&theme->styles.page_indicator_dot_current);
lv_style_set_bg_opa(&theme->styles.page_indicator_dot_current, LV_OPA_COVER);
style_init_reset(&theme->styles.badge); style_init_reset(&theme->styles.badge);
lv_style_set_width(&theme->styles.badge, BADGE_SIZE); lv_style_set_width(&theme->styles.badge, BADGE_SIZE);
lv_style_set_height(&theme->styles.badge, BADGE_SIZE); lv_style_set_height(&theme->styles.badge, BADGE_SIZE);
@ -1802,6 +1836,11 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
return; return;
} }
#endif #endif
/* page_indicator > label */
if(lv_obj_check_type(parent, &lvgl_page_indicator_class)) {
lv_obj_add_style(obj, &theme->styles.page_indicator_label, 0);
return;
}
return; return;
} }
#endif #endif
@ -1833,6 +1872,17 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
return; return;
} }
#endif #endif
if(lv_obj_check_type(obj, &lvgl_page_indicator_class)) {
/* page_indicator */
lv_obj_add_style(obj, &theme->styles.page_indicator, 0);
return;
}
if(lv_obj_check_type(obj, &lvgl_page_indicator_dot_class)) {
/* page_indicator_dot */
lv_obj_add_style(obj, &theme->styles.page_indicator_dot, 0);
lv_obj_add_style(obj, &theme->styles.page_indicator_dot_current, LV_STATE_CHECKED);
return;
}
if(lv_obj_check_type(obj, &lvgl_badge_class)) { if(lv_obj_check_type(obj, &lvgl_badge_class)) {
/* badge */ /* badge */
lv_obj_add_style(obj, &theme->styles.badge, 0); lv_obj_add_style(obj, &theme->styles.badge, 0);

View File

@ -16,6 +16,8 @@ extern const lv_obj_class_t lvgl_sliderbox_class;
extern const lv_obj_class_t lvgl_card_class; extern const lv_obj_class_t lvgl_card_class;
extern const lv_obj_class_t lvgl_chip_class; extern const lv_obj_class_t lvgl_chip_class;
extern const lv_obj_class_t lvgl_badge_class; extern const lv_obj_class_t lvgl_badge_class;
extern const lv_obj_class_t lvgl_page_indicator_class;
extern const lv_obj_class_t lvgl_page_indicator_dot_class;
#define BLACK lv_color_black() #define BLACK lv_color_black()
#define WHITE lv_color_white() #define WHITE lv_color_white()
@ -87,6 +89,9 @@ extern const lv_obj_class_t lvgl_badge_class;
#define CHIP_SHAPE (theme->config.is_compact ? CORNER_COMPACT : CORNER_SMALL) #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_HOR (theme->config.is_compact ? 3 : SPACE_4)
#define CHIP_PAD_VER (theme->config.is_compact ? 2 : SPACE_2) #define CHIP_PAD_VER (theme->config.is_compact ? 2 : SPACE_2)
#define PAGE_INDICATOR_COLOR COLOR_ON_SURFACE
#define PAGE_INDICATOR_DOT_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6))
#define PAGE_INDICATOR_GAP (theme->config.is_compact ? 3 : LV_DPX_CALC(theme->disp_dpi, 6))
#define BADGE_COLOR COLOR_ON_SURFACE #define BADGE_COLOR COLOR_ON_SURFACE
#define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) #define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6))
#define SCROLLBAR_COLOR COLOR_ON_SURFACE #define SCROLLBAR_COLOR COLOR_ON_SURFACE
@ -216,6 +221,12 @@ typedef struct {
lv_style_t screen; lv_style_t screen;
lv_style_t container; lv_style_t container;
lv_style_t card; lv_style_t card;
lv_style_t page_indicator;
#if LV_USE_LABEL
lv_style_t page_indicator_label;
#endif
lv_style_t page_indicator_dot;
lv_style_t page_indicator_dot_current;
lv_style_t badge; lv_style_t badge;
lv_style_t chip; lv_style_t chip;
#if LV_USE_BUTTON || LV_USE_MSGBOX #if LV_USE_BUTTON || LV_USE_MSGBOX
@ -548,6 +559,29 @@ static void style_init(my_theme_t * theme)
lv_style_set_pad_all(&theme->styles.card, CARD_PAD); lv_style_set_pad_all(&theme->styles.card, CARD_PAD);
lv_style_set_pad_gap(&theme->styles.card, CARD_GAP); lv_style_set_pad_gap(&theme->styles.card, CARD_GAP);
style_init_reset(&theme->styles.page_indicator);
lv_style_set_bg_opa(&theme->styles.page_indicator, LV_OPA_TRANSP);
lv_style_set_border_width(&theme->styles.page_indicator, 0);
lv_style_set_pad_all(&theme->styles.page_indicator, 0);
lv_style_set_pad_gap(&theme->styles.page_indicator, PAGE_INDICATOR_GAP);
#if LV_USE_LABEL
style_init_reset(&theme->styles.page_indicator_label);
lv_style_set_text_color(&theme->styles.page_indicator_label, PAGE_INDICATOR_COLOR);
#endif
style_init_reset(&theme->styles.page_indicator_dot);
lv_style_set_width(&theme->styles.page_indicator_dot, PAGE_INDICATOR_DOT_SIZE);
lv_style_set_height(&theme->styles.page_indicator_dot, PAGE_INDICATOR_DOT_SIZE);
lv_style_set_radius(&theme->styles.page_indicator_dot, LV_RADIUS_CIRCLE);
lv_style_set_bg_color(&theme->styles.page_indicator_dot, PAGE_INDICATOR_COLOR);
lv_style_set_bg_opa(&theme->styles.page_indicator_dot, LV_OPA_TRANSP);
lv_style_set_border_color(&theme->styles.page_indicator_dot, PAGE_INDICATOR_COLOR);
lv_style_set_border_width(&theme->styles.page_indicator_dot, 1);
style_init_reset(&theme->styles.page_indicator_dot_current);
lv_style_set_bg_opa(&theme->styles.page_indicator_dot_current, LV_OPA_COVER);
style_init_reset(&theme->styles.badge); style_init_reset(&theme->styles.badge);
lv_style_set_width(&theme->styles.badge, BADGE_SIZE); lv_style_set_width(&theme->styles.badge, BADGE_SIZE);
lv_style_set_height(&theme->styles.badge, BADGE_SIZE); lv_style_set_height(&theme->styles.badge, BADGE_SIZE);
@ -1401,6 +1435,11 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
return; return;
} }
#endif #endif
/* page_indicator > label */
if(lv_obj_check_type(parent, &lvgl_page_indicator_class)) {
lv_obj_add_style(obj, &theme->styles.page_indicator_label, 0);
return;
}
return; return;
} }
#endif #endif
@ -1428,6 +1467,17 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
return; return;
} }
#endif #endif
if(lv_obj_check_type(obj, &lvgl_page_indicator_class)) {
/* page_indicator */
lv_obj_add_style(obj, &theme->styles.page_indicator, 0);
return;
}
if(lv_obj_check_type(obj, &lvgl_page_indicator_dot_class)) {
/* page_indicator_dot */
lv_obj_add_style(obj, &theme->styles.page_indicator_dot, 0);
lv_obj_add_style(obj, &theme->styles.page_indicator_dot_current, LV_STATE_CHECKED);
return;
}
if(lv_obj_check_type(obj, &lvgl_badge_class)) { if(lv_obj_check_type(obj, &lvgl_badge_class)) {
/* badge */ /* badge */
lv_obj_add_style(obj, &theme->styles.badge, 0); lv_obj_add_style(obj, &theme->styles.badge, 0);

View File

@ -0,0 +1,134 @@
// SPDX-License-Identifier: Apache-2.0
#define LV_USE_PRIVATE_API 1 // For the lv_obj_class_t declaration
#include <lvgl/widgets/page_indicator.h>
#include <cstdio>
static void refresh(lv_obj_t* obj);
static void on_style_changed(lv_event_t* event) {
refresh(lv_event_get_current_target_obj(event));
}
static void page_indicator_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj) {
LV_UNUSED(class_p);
lv_obj_remove_flag(obj, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
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);
// Shows the page numbers instead of the dots when they don't fit in the indicator's maximum width
lv_obj_t* label = lv_label_create(obj);
lv_obj_add_flag(label, LV_OBJ_FLAG_HIDDEN);
// A change of the maximum width can switch between dots and numbers
lv_obj_add_event_cb(obj, on_style_changed, LV_EVENT_STYLE_CHANGED, nullptr);
}
static void page_indicator_dot_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj) {
LV_UNUSED(class_p);
lv_obj_remove_flag(obj, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
}
// Not static: the theme styles page indicators and their dots by these classes
extern "C" const lv_obj_class_t lvgl_page_indicator_class = {
.base_class = &lv_obj_class,
.constructor_cb = page_indicator_constructor,
.destructor_cb = nullptr,
.event_cb = nullptr,
.user_data = nullptr,
.name = "page_indicator",
.width_def = LV_SIZE_CONTENT,
.height_def = LV_SIZE_CONTENT,
.editable = LV_OBJ_CLASS_EDITABLE_INHERIT,
.group_def = LV_OBJ_CLASS_GROUP_DEF_FALSE,
.instance_size = 0,
.theme_inheritable = LV_OBJ_CLASS_THEME_INHERITABLE_FALSE
};
extern "C" const lv_obj_class_t lvgl_page_indicator_dot_class = {
.base_class = &lv_obj_class,
.constructor_cb = page_indicator_dot_constructor,
.destructor_cb = nullptr,
.event_cb = nullptr,
.user_data = nullptr,
.name = "page_indicator_dot",
.width_def = 0,
.height_def = 0,
.editable = LV_OBJ_CLASS_EDITABLE_INHERIT,
.group_def = LV_OBJ_CLASS_GROUP_DEF_FALSE,
.instance_size = 0,
.theme_inheritable = LV_OBJ_CLASS_THEME_INHERITABLE_FALSE
};
// The first child is the label, the dots follow
static lv_obj_t* get_label(lv_obj_t* obj) {
return lv_obj_get_child(obj, 0);
}
static uint32_t get_dot_count(lv_obj_t* obj) {
return lv_obj_get_child_count(obj) - 1;
}
static lv_obj_t* get_dot(lv_obj_t* obj, uint32_t index) {
return lv_obj_get_child(obj, static_cast<int32_t>(index + 1));
}
static void refresh(lv_obj_t* obj) {
const uint32_t count = get_dot_count(obj);
if (count == 0) {
return;
}
const int32_t dot_width = lv_obj_get_style_width(get_dot(obj, 0), LV_PART_MAIN);
const int32_t gap = lv_obj_get_style_pad_column(obj, LV_PART_MAIN);
const int32_t dots_width = static_cast<int32_t>(count) * dot_width + static_cast<int32_t>(count - 1) * gap;
const bool show_numbers = dots_width > lv_obj_get_style_max_width(obj, LV_PART_MAIN);
lv_obj_t* label = get_label(obj);
lv_obj_set_flag(label, LV_OBJ_FLAG_HIDDEN, !show_numbers);
uint32_t page = 0;
for (uint32_t i = 0; i < count; i++) {
lv_obj_t* dot = get_dot(obj, i);
lv_obj_set_flag(dot, LV_OBJ_FLAG_HIDDEN, show_numbers);
if (lv_obj_has_state(dot, LV_STATE_CHECKED)) {
page = i;
}
}
if (show_numbers) {
char text[24];
std::snprintf(text, sizeof(text), "%lu/%lu", static_cast<unsigned long>(page + 1), static_cast<unsigned long>(count));
lv_label_set_text(label, text);
}
}
extern "C" {
lv_obj_t* lvgl_page_indicator_create(lv_obj_t* parent) {
lv_obj_t* obj = lv_obj_class_create_obj(&lvgl_page_indicator_class, parent);
lv_obj_class_init_obj(obj);
return obj;
}
void lvgl_page_indicator_set_page_count(lv_obj_t* obj, uint32_t count) {
uint32_t current = get_dot_count(obj);
while (current > count) {
lv_obj_delete(lv_obj_get_child(obj, -1));
current--;
}
while (current < count) {
lv_obj_t* dot = lv_obj_class_create_obj(&lvgl_page_indicator_dot_class, obj);
lv_obj_class_init_obj(dot);
current++;
}
refresh(obj);
}
void lvgl_page_indicator_set_page(lv_obj_t* obj, uint32_t page) {
const uint32_t count = get_dot_count(obj);
for (uint32_t i = 0; i < count; i++) {
lv_obj_set_state(get_dot(obj, i), LV_STATE_CHECKED, i == page);
}
refresh(obj);
}
}

View File

@ -29,6 +29,8 @@
@widget card: lvgl_card_class; @widget card: lvgl_card_class;
@widget chip: lvgl_chip_class; @widget chip: lvgl_chip_class;
@widget badge: lvgl_badge_class; @widget badge: lvgl_badge_class;
@widget page_indicator: lvgl_page_indicator_class;
@widget page_indicator_dot: lvgl_page_indicator_dot_class;
// ============================================================ // ============================================================
// 1. Base tokens // 1. Base tokens
@ -199,6 +201,11 @@
@chip-pad-hor: if($isCompact, 3, @space-4); @chip-pad-hor: if($isCompact, 3, @space-4);
@chip-pad-ver: if($isCompact, 2, 6dp); @chip-pad-ver: if($isCompact, 2, 6dp);
// Page indicator (lvgl_page_indicator_create): a dot per page, the current page's dot is filled
@page-indicator-color: @color-on-surface-variant;
@page-indicator-dot-size: if($isCompact, 4, 6dp);
@page-indicator-gap: if($isCompact, 3, 6dp);
// Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite // Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite
@badge-color: @color-secondary; @badge-color: @color-secondary;
@badge-size: if($isCompact, 4, 6dp); @badge-size: if($isCompact, 4, 6dp);
@ -565,6 +572,31 @@ card {
padGap: @card-gap; padGap: @card-gap;
} }
page_indicator {
bgOpa: transp;
borderWidth: 0;
padAll: 0;
padGap: @page-indicator-gap;
}
page_indicator_label {
textColor: @page-indicator-color;
}
page_indicator_dot {
width: @page-indicator-dot-size;
height: @page-indicator-dot-size;
radius: circle;
bgColor: @page-indicator-color;
bgOpa: transp;
borderColor: @page-indicator-color;
borderWidth: 1;
}
page_indicator_dot_current {
bgOpa: cover;
}
badge { badge {
width: @badge-size; width: @badge-size;
height: @badge-size; height: @badge-size;
@ -1374,6 +1406,19 @@ apply(checkbox) {
disabled: state.disabled; disabled: state.disabled;
} }
apply(page_indicator) {
page_indicator: default;
}
apply(page_indicator > label) {
page_indicator_label: default;
}
apply(page_indicator_dot) {
page_indicator_dot: default;
page_indicator_dot_current: state.checked;
}
apply(badge) { apply(badge) {
badge: default; badge: default;
} }

View File

@ -30,6 +30,8 @@
@widget card: lvgl_card_class; @widget card: lvgl_card_class;
@widget chip: lvgl_chip_class; @widget chip: lvgl_chip_class;
@widget badge: lvgl_badge_class; @widget badge: lvgl_badge_class;
@widget page_indicator: lvgl_page_indicator_class;
@widget page_indicator_dot: lvgl_page_indicator_dot_class;
// ============================================================ // ============================================================
// 1. Base tokens // 1. Base tokens
@ -142,6 +144,11 @@
@chip-pad-hor: if($isCompact, 3, @space-4); @chip-pad-hor: if($isCompact, 3, @space-4);
@chip-pad-ver: if($isCompact, 2, @space-2); @chip-pad-ver: if($isCompact, 2, @space-2);
// Page indicator (lvgl_page_indicator_create): a dot per page, the current page's dot is filled
@page-indicator-color: @color-on-surface;
@page-indicator-dot-size: if($isCompact, 4, 6dp);
@page-indicator-gap: if($isCompact, 3, 6dp);
// Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite // Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite
@badge-color: @color-on-surface; @badge-color: @color-on-surface;
@badge-size: if($isCompact, 4, 6dp); @badge-size: if($isCompact, 4, 6dp);
@ -392,6 +399,31 @@ card {
padGap: @card-gap; padGap: @card-gap;
} }
page_indicator {
bgOpa: transp;
borderWidth: 0;
padAll: 0;
padGap: @page-indicator-gap;
}
page_indicator_label {
textColor: @page-indicator-color;
}
page_indicator_dot {
width: @page-indicator-dot-size;
height: @page-indicator-dot-size;
radius: circle;
bgColor: @page-indicator-color;
bgOpa: transp;
borderColor: @page-indicator-color;
borderWidth: 1;
}
page_indicator_dot_current {
bgOpa: cover;
}
badge { badge {
width: @badge-size; width: @badge-size;
height: @badge-size; height: @badge-size;
@ -1037,6 +1069,19 @@ apply(checkbox) {
disabled: part.indicator, state.disabled; disabled: part.indicator, state.disabled;
} }
apply(page_indicator) {
page_indicator: default;
}
apply(page_indicator > label) {
page_indicator_label: default;
}
apply(page_indicator_dot) {
page_indicator_dot: default;
page_indicator_dot_current: state.checked;
}
apply(badge) { apply(badge) {
badge: default; badge: default;
} }

View File

@ -39,18 +39,36 @@ public:
explicit AppGrid(const Callbacks& callbacks) : callbacks(callbacks) {} explicit AppGrid(const Callbacks& callbacks) : callbacks(callbacks) {}
/** Shows the tile icons in the theme's primary color. Call before creating the widgets. */ enum class IconColor {
void setPrimaryColorIcons(bool enabled) { primaryColorIcons = enabled; } /** The theme's icon colour */
Default,
/** The theme's primary colour */
Primary,
/** The theme's secondary colour */
Secondary
};
/** Sets the colour of the tile icons. Call before creating the widgets. */
void setIconColor(IconColor color) { iconColor = color; }
/** Swiping left or right over the grid goes to the next or previous page. Call before creating the widgets. */
void setSwipeNavigation(bool enabled) { swipeNavigation = 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. * Creates the grid and populates it, with a bar below it: buttons on the left (see addBarButton()),
* @param[in] parent the container for the grid * a page indicator in the center and page buttons on the right. The page indicator and buttons are
* @param[in] pageBar an empty container * hidden when there's only one page, and the page buttons wrap around.
* @param[in] parent the container for the grid and the bar
*/ */
void createWidgetsWithPageBar(lv_obj_t* parent, lv_obj_t* pageBar); void createWidgetsWithBottomBar(lv_obj_t* parent);
/**
* Adds an icon button to the left side of the bottom bar. Call right after createWidgetsWithBottomBar().
* @param[in] icon a shared icon (see lvgl/icons/shared.h)
* @return the button
*/
lv_obj_t* addBarButton(const char* icon, lv_event_cb_t onClicked, void* userData);
/** /**
* Rebuilds the grid asynchronously, so it is safe to call from a tile's own event. * Rebuilds the grid asynchronously, so it is safe to call from a tile's own event.
@ -64,22 +82,29 @@ private:
std::vector<AppGridItem> items; std::vector<AppGridItem> items;
uint32_t page = 0; uint32_t page = 0;
uint32_t pageCount = 1; uint32_t pageCount = 1;
bool primaryColorIcons = false; IconColor iconColor = IconColor::Default;
bool swipeNavigation = false;
bool keepSelectionOnRepopulate = true; bool keepSelectionOnRepopulate = true;
lv_obj_t* grid = nullptr; lv_obj_t* grid = nullptr;
lv_obj_t* prevButton = nullptr; lv_obj_t* prevButton = nullptr;
lv_obj_t* nextButton = nullptr; lv_obj_t* nextButton = nullptr;
lv_obj_t* pageBar = nullptr; lv_obj_t* pageIndicator = nullptr;
lv_obj_t* pageLabel = nullptr; lv_obj_t* pageButtons = nullptr;
lv_obj_t* barButtons = nullptr;
void createGrid(lv_obj_t* parent); void createGrid(lv_obj_t* parent);
void goToPreviousPage();
void goToNextPage();
void populate(); void populate();
static void onDeferredRepopulate(void* userData); static void onDeferredRepopulate(void* userData);
static void onDeferredNextPage(void* userData);
static void onDeferredPreviousPage(void* userData);
static void onGridDeleted(lv_event_t* e); static void onGridDeleted(lv_event_t* e);
static void onGridSizeChanged(lv_event_t* e); static void onGridSizeChanged(lv_event_t* e);
static void onPrevPressed(lv_event_t* e); static void onPrevPressed(lv_event_t* e);
static void onNextPressed(lv_event_t* e); static void onNextPressed(lv_event_t* e);
static void onGridGesture(lv_event_t* e);
static void onTileClicked(lv_event_t* e); static void onTileClicked(lv_event_t* e);
static void onTileLongPressed(lv_event_t* e); static void onTileLongPressed(lv_event_t* e);
static void onTileKey(lv_event_t* e); static void onTileKey(lv_event_t* e);

View File

@ -3,9 +3,10 @@
#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/grid_navigation.h>
#include <lvgl/insets.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/page_indicator.h>
#include <algorithm> #include <algorithm>
#include <string> #include <string>
@ -54,8 +55,67 @@ const AppGridItem* getTileItem(lv_event_t* e) {
return static_cast<const AppGridItem*>(lv_obj_get_user_data(lv_event_get_target_obj(e))); return static_cast<const AppGridItem*>(lv_obj_get_user_data(lv_event_get_target_obj(e)));
} }
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;
}
// The width that a section's visible children need, including the gaps between them
int32_t getContentWidth(lv_obj_t* section) {
int32_t width = 0;
uint32_t visible = 0;
const uint32_t count = lv_obj_get_child_count(section);
for (uint32_t i = 0; i < count; i++) {
lv_obj_t* child = lv_obj_get_child(section, static_cast<int32_t>(i));
if (!lv_obj_has_flag(child, LV_OBJ_FLAG_HIDDEN)) {
width += lv_obj_get_width(child);
visible++;
}
}
return visible > 1 ? width + static_cast<int32_t>(visible - 1) * lv_obj_get_style_pad_column(section, LV_PART_MAIN) : width;
}
// The page indicator gets the space between the side sections, which grow equally to keep it centered
void updatePageIndicatorWidth(void* data) {
auto* bottom_bar = static_cast<lv_obj_t*>(data);
auto* left_section = lv_obj_get_child(bottom_bar, 0);
auto* indicator_section = lv_obj_get_child(bottom_bar, 1);
auto* page_buttons = lv_obj_get_child(bottom_bar, 2);
auto* indicator = lv_obj_get_child(indicator_section, 0);
if (indicator == nullptr) {
return;
}
const int32_t side_width = std::max(getContentWidth(left_section), getContentWidth(page_buttons));
const int32_t gaps = 2 * lv_obj_get_style_pad_column(bottom_bar, LV_PART_MAIN);
const int32_t available = lv_obj_get_content_width(bottom_bar) - 2 * side_width - gaps;
lv_obj_set_style_max_width(indicator, std::max<int32_t>(0, available), LV_STATE_DEFAULT);
}
void onBottomBarSizeChanged(lv_event_t* event) {
lv_async_call(updatePageIndicatorWidth, lv_event_get_target_obj(event));
}
void onBottomBarDeleted(lv_event_t* event) {
lv_async_call_cancel(updatePageIndicatorWidth, lv_event_get_target_obj(event));
}
} // namespace } // namespace
void AppGrid::onDeferredNextPage(void* userData) {
static_cast<AppGrid*>(userData)->goToNextPage();
}
void AppGrid::onDeferredPreviousPage(void* userData) {
static_cast<AppGrid*>(userData)->goToPreviousPage();
}
void AppGrid::onDeferredRepopulate(void* userData) { void AppGrid::onDeferredRepopulate(void* userData) {
static_cast<AppGrid*>(userData)->populate(); static_cast<AppGrid*>(userData)->populate();
} }
@ -63,6 +123,8 @@ void AppGrid::onDeferredRepopulate(void* userData) {
void AppGrid::onGridDeleted(lv_event_t* e) { void AppGrid::onGridDeleted(lv_event_t* e) {
// Cancels a still-pending repopulate scheduled right before the grid itself was deleted (e.g. the app closing) // Cancels a still-pending repopulate scheduled right before the grid itself was deleted (e.g. the app closing)
lv_async_call_cancel(onDeferredRepopulate, lv_event_get_user_data(e)); lv_async_call_cancel(onDeferredRepopulate, lv_event_get_user_data(e));
lv_async_call_cancel(onDeferredNextPage, lv_event_get_user_data(e));
lv_async_call_cancel(onDeferredPreviousPage, lv_event_get_user_data(e));
} }
// Deferred: page size depends on the grid size, which is final only after the layout pass that triggered this. // Deferred: page size depends on the grid size, which is final only after the layout pass that triggered this.
@ -70,28 +132,42 @@ void AppGrid::onGridSizeChanged(lv_event_t* e) {
lv_async_call(onDeferredRepopulate, lv_event_get_user_data(e)); lv_async_call(onDeferredRepopulate, lv_event_get_user_data(e));
} }
void AppGrid::onPrevPressed(lv_event_t* e) { // Changing the page doesn't keep the selected app selected: that would return to the selected app's page
auto* self = static_cast<AppGrid*>(lv_event_get_user_data(e)); void AppGrid::goToPreviousPage() {
if (self->page > 0) { page = page > 0 ? page - 1 : pageCount - 1;
self->page--; keepSelectionOnRepopulate = false;
} else if (self->pageBar != nullptr) { populate();
self->page = self->pageCount - 1;
} else {
return;
} }
self->populate();
void AppGrid::goToNextPage() {
page = page + 1 < pageCount ? page + 1 : 0;
keepSelectionOnRepopulate = false;
populate();
}
void AppGrid::onPrevPressed(lv_event_t* e) {
static_cast<AppGrid*>(lv_event_get_user_data(e))->goToPreviousPage();
} }
void AppGrid::onNextPressed(lv_event_t* e) { void AppGrid::onNextPressed(lv_event_t* e) {
static_cast<AppGrid*>(lv_event_get_user_data(e))->goToNextPage();
}
// Swipes that start on a tile reach the grid too, as tiles pass their gestures on to the grid
void AppGrid::onGridGesture(lv_event_t* e) {
auto* self = static_cast<AppGrid*>(lv_event_get_user_data(e)); auto* self = static_cast<AppGrid*>(lv_event_get_user_data(e));
if (self->page + 1 < self->pageCount) { lv_indev_t* indev = lv_indev_active();
self->page++; if (indev == nullptr || self->pageCount <= 1) {
} else if (self->pageBar != nullptr) {
self->page = 0;
} else {
return; return;
} }
self->populate(); const lv_dir_t direction = lv_indev_get_gesture_dir(indev);
if (direction != LV_DIR_LEFT && direction != LV_DIR_RIGHT) {
return;
}
// Keeps the tile where the swipe started from being clicked when the finger is lifted
lv_indev_wait_release(indev);
// Deferred: changing the page deletes the tiles, possibly including the one this event came from
lv_async_call(direction == LV_DIR_LEFT ? onDeferredNextPage : onDeferredPreviousPage, self);
} }
void AppGrid::onTileClicked(lv_event_t* e) { void AppGrid::onTileClicked(lv_event_t* e) {
@ -119,8 +195,9 @@ void AppGrid::populate() {
// replacement tiles doesn't restore the focus, so the app id is remembered here and // replacement tiles doesn't restore the focus, 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;
const bool keepSelection = keepSelectionOnRepopulate;
lv_group_t* group = lv_group_get_default(); lv_group_t* group = lv_group_get_default();
if (group != nullptr && keepSelectionOnRepopulate) { if (group != nullptr && keepSelection) {
// Grid navigation focuses the grid in the group, and a tile within the grid // 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; 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) {
@ -152,15 +229,10 @@ void AppGrid::populate() {
page = std::min(page, page_count - 1); page = std::min(page, page_count - 1);
pageCount = page_count; pageCount = page_count;
if (pageBar != nullptr) { lv_obj_set_flag(pageIndicator, LV_OBJ_FLAG_HIDDEN, page_count <= 1);
lv_obj_set_flag(pageBar, LV_OBJ_FLAG_HIDDEN, page_count <= 1); lv_obj_set_flag(pageButtons, LV_OBJ_FLAG_HIDDEN, page_count <= 1);
lv_label_set_text_fmt(pageLabel, "%lu/%lu", static_cast<unsigned long>(page + 1), static_cast<unsigned long>(page_count)); lvgl_page_indicator_set_page_count(pageIndicator, page_count);
} else { lvgl_page_indicator_set_page(pageIndicator, page);
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; lv_obj_t* focusedTile = nullptr;
const uint32_t first = page * layout.pageSize; const uint32_t first = page * layout.pageSize;
@ -182,8 +254,10 @@ void AppGrid::populate() {
lv_obj_set_style_text_align(icon, LV_TEXT_ALIGN_CENTER, LV_STATE_DEFAULT); lv_obj_set_style_text_align(icon, LV_TEXT_ALIGN_CENTER, LV_STATE_DEFAULT);
lv_obj_set_size(icon, layout.iconSize, layout.iconSize); lv_obj_set_size(icon, layout.iconSize, layout.iconSize);
lv_label_set_text(icon, item.icon); lv_label_set_text(icon, item.icon);
if (primaryColorIcons) { if (iconColor == IconColor::Primary) {
lv_obj_set_style_text_color(icon, lv_theme_get_color_primary(icon), LV_STATE_DEFAULT); lv_obj_set_style_text_color(icon, lv_theme_get_color_primary(icon), LV_STATE_DEFAULT);
} else if (iconColor == IconColor::Secondary) {
lv_obj_set_style_text_color(icon, lv_theme_get_color_secondary(icon), LV_STATE_DEFAULT);
} }
lv_obj_t* label = lv_label_create(tile); lv_obj_t* label = lv_label_create(tile);
@ -216,6 +290,10 @@ void AppGrid::populate() {
if (focusedTile != nullptr) { if (focusedTile != nullptr) {
lv_group_focus_obj(grid); lv_group_focus_obj(grid);
lv_gridnav_set_focused(grid, focusedTile, LV_ANIM_OFF); lv_gridnav_set_focused(grid, focusedTile, LV_ANIM_OFF);
} else if (!keepSelection && group != nullptr && lv_group_get_focused(group) == grid) {
// Grid navigation selects the first new tile when the grid is focused, but a page change shows no selection.
// The next key that moves the focus selects a tile again.
lvgl_focus_hide_key_selection(group);
} }
} }
@ -232,42 +310,38 @@ void AppGrid::createGrid(lv_obj_t* parent) {
// The arrow keys move between the tiles in rows and columns // The arrow keys move between the tiles in rows and columns
lvgl_grid_navigation_add(grid); lvgl_grid_navigation_add(grid);
lv_obj_add_event_cb(grid, onGridDeleted, LV_EVENT_DELETE, this); lv_obj_add_event_cb(grid, onGridDeleted, LV_EVENT_DELETE, this);
if (swipeNavigation) {
// LVGL sends a gesture to the first object, starting at the touched one, that doesn't pass it on to its parent
lv_obj_remove_flag(grid, LV_OBJ_FLAG_GESTURE_BUBBLE);
lv_obj_add_event_cb(grid, onGridGesture, LV_EVENT_GESTURE, this);
}
} }
void AppGrid::createWidgets(lv_obj_t* parent, lv_obj_t* toolbar) { void AppGrid::createWidgetsWithBottomBar(lv_obj_t* parent) {
prevButton = lvgl_toolbar_add_text_button_action(toolbar, "<", onPrevPressed, this); // The side sections grow equally, so the page indicator stays centered
nextButton = lvgl_toolbar_add_text_button_action(toolbar, ">", onNextPressed, this); 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);
barButtons = createBarSection(bottom_bar, LV_FLEX_ALIGN_START);
lv_obj_set_flex_grow(barButtons, 1);
auto* indicator_section = createBarSection(bottom_bar, LV_FLEX_ALIGN_CENTER);
pageButtons = createBarSection(bottom_bar, LV_FLEX_ALIGN_END);
lv_obj_set_flex_grow(pageButtons, 1);
lv_obj_add_event_cb(bottom_bar, onBottomBarSizeChanged, LV_EVENT_SIZE_CHANGED, nullptr);
lv_obj_add_event_cb(bottom_bar, onBottomBarDeleted, LV_EVENT_DELETE, nullptr);
createGrid(parent); createGrid(parent);
lv_obj_move_foreground(bottom_bar);
// Resolves the grid's flex_grow height, which the page size is derived from. pageIndicator = lvgl_page_indicator_create(indicator_section);
lv_obj_update_layout(parent); prevButton = lvgl_icon_button_create(pageButtons);
lv_obj_add_event_cb(grid, onGridSizeChanged, LV_EVENT_SIZE_CHANGED, this);
populate();
const bool next_visible = !lv_obj_has_flag(nextButton, LV_OBJ_FLAG_HIDDEN);
if (next_visible && !lvgl_indev_exists(LV_INDEV_TYPE_POINTER)) {
lv_group_focus_obj(nextButton);
lv_obj_add_state(nextButton, LV_STATE_FOCUS_KEY);
}
}
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_label_set_text(lv_label_create(prevButton), "<");
lv_obj_add_event_cb(prevButton, onPrevPressed, LV_EVENT_SHORT_CLICKED, this); lv_obj_add_event_cb(prevButton, onPrevPressed, LV_EVENT_SHORT_CLICKED, this);
nextButton = lvgl_icon_button_create(pageBar); nextButton = lvgl_icon_button_create(pageButtons);
lv_label_set_text(lv_label_create(nextButton), ">"); lv_label_set_text(lv_label_create(nextButton), ">");
lv_obj_add_event_cb(nextButton, onNextPressed, LV_EVENT_SHORT_CLICKED, this); lv_obj_add_event_cb(nextButton, onNextPressed, LV_EVENT_SHORT_CLICKED, this);
// The arrow keys move between the page buttons // The arrow keys move between the page buttons
lvgl_grid_navigation_add(pageBar); lvgl_grid_navigation_add(pageButtons);
// 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);
@ -281,4 +355,13 @@ void AppGrid::createWidgetsWithPageBar(lv_obj_t* parent, lv_obj_t* pageBar) {
} }
} }
lv_obj_t* AppGrid::addBarButton(const char* icon, lv_event_cb_t onClicked, void* userData) {
auto* button = lvgl_icon_button_create(barButtons);
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, onClicked, LV_EVENT_SHORT_CLICKED, userData);
return button;
}
} }

View File

@ -11,11 +11,8 @@
#include <vector> #include <vector>
#include <lvgl.h> #include <lvgl.h>
#include <lvgl/fonts.h>
#include <lvgl/icons/shared.h> #include <lvgl/icons/shared.h>
#include <lvgl/insets.h>
#include <lvgl/theme.h> #include <lvgl/theme.h>
#include <lvgl/widgets/icon_button.h>
#include <lvgl_window_manager/window_manager.h> #include <lvgl_window_manager/window_manager.h>
@ -49,7 +46,6 @@ constexpr IconEntry ICONS[] = {
{"tactility.i2cscanner", LVGL_ICON_SHARED_CABLE}, {"tactility.i2cscanner", LVGL_ICON_SHARED_CABLE},
{"tactility.notes", LVGL_ICON_SHARED_EDIT_NOTE}, {"tactility.notes", LVGL_ICON_SHARED_EDIT_NOTE},
{"tactility.screenshot", LVGL_ICON_SHARED_IMAGE}, {"tactility.screenshot", LVGL_ICON_SHARED_IMAGE},
{"tactility.settings", LVGL_ICON_SHARED_SETTINGS},
{"tactility.systeminfo", LVGL_ICON_SHARED_DEVICES}, {"tactility.systeminfo", LVGL_ICON_SHARED_DEVICES},
{"tactility.terminal", LVGL_ICON_SHARED_TERMINAL}, {"tactility.terminal", LVGL_ICON_SHARED_TERMINAL},
{"tactility.webserversettings", LVGL_ICON_SHARED_CLOUD}, {"tactility.webserversettings", LVGL_ICON_SHARED_CLOUD},
@ -58,7 +54,6 @@ constexpr IconEntry ICONS[] = {
// Hidden apps that are still shown in the launcher // Hidden apps that are still shown in the launcher
constexpr const char* SHOWN_HIDDEN_APP_IDS[] = { constexpr const char* SHOWN_HIDDEN_APP_IDS[] = {
"tactility.files", "tactility.files",
"tactility.settings",
}; };
const char* appIcon(const ::AppManifest* manifest) { const char* appIcon(const ::AppManifest* manifest) {
@ -187,46 +182,21 @@ bool supportsPowerOff() {
return supported; return supported;
} }
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<char*>(appId));
}
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* userData) { void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData); auto* ctx = static_cast<Context*>(userData);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_row(parent, 0, 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);
// The monochrome theme's primary color is black, also on a black background // The monochrome theme's primary color is black, also on a black background
ctx->grid.setPrimaryColorIcons(!lvgl_theme_is_mono()); if (!lvgl_theme_is_mono()) {
ctx->grid.createWidgetsWithPageBar(parent, page_bar); ctx->grid.setIconColor(AppGrid::IconColor::Primary);
lv_obj_move_foreground(bottom_bar); }
ctx->grid.setSwipeNavigation(true);
ctx->grid.createWidgetsWithBottomBar(parent);
ctx->grid.addBarButton(LVGL_ICON_SHARED_SETTINGS, onShortcutPressed, const_cast<char*>("tactility.settings"));
if (isAppRegistered("tactility.poweroff") && supportsPowerOff()) { if (isAppRegistered("tactility.poweroff") && supportsPowerOff()) {
addShortcut(left_section, LVGL_ICON_SHARED_POWER_SETTINGS_NEW, "tactility.poweroff"); ctx->grid.addBarButton(LVGL_ICON_SHARED_POWER_SETTINGS_NEW, onShortcutPressed, const_cast<char*>("tactility.poweroff"));
} }
} }

View File

@ -9,7 +9,7 @@
#include <Tactility/app/AppGrid.h> #include <Tactility/app/AppGrid.h>
#include <lvgl/icons/shared.h> #include <lvgl/icons/shared.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/theme.h>
#include <tactility/check.h> #include <tactility/check.h>
#include <lvgl.h> #include <lvgl.h>
@ -109,9 +109,12 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
auto* toolbar = lvgl_toolbar_create(parent, "Settings"); if (!lvgl_theme_is_mono()) {
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); ctx->grid.setIconColor(AppGrid::IconColor::Secondary);
ctx->grid.createWidgets(parent, toolbar); }
ctx->grid.setSwipeNavigation(true);
ctx->grid.createWidgetsWithBottomBar(parent);
ctx->grid.addBarButton(LVGL_ICON_SHARED_CLOSE, onBackPressed, ctx);
} }
int32_t appMain(int argc, char* argv[]) { int32_t appMain(int argc, char* argv[]) {