feat: added viewport scrolling

This commit is contained in:
aZholtikov committed 2026-10-27 09:07:13 +03:00
1 parent 4116a94c5c
commit 4377cbf7b9
5 files changed
+211 -15

No files matched your search

+20 -5
View File
@@ -14,8 +14,9 @@ which is distributed under the Apache License, Version 2.0. The original code ha
5. Support for simple action items that trigger a one-shot callback on selection.
6. Built-in navigation stack that allows returning to the previous menu level.
7. Optional cyclic navigation mode for scrolling through menu items in a loop.
8. Minimal memory footprint with a single dynamically allocated handle and no per-node heap usage.
9. Built-in statistics for queue overflow errors and task stack watermark.
8. Viewport scrolling: only `viewport_rows` items are shown at once, the selected item always stays visible, and `↑`/`↓` (can be changed) indicators mark hidden items above/below the window. The menu header is always rendered separately and is never scrolled.
9. Minimal memory footprint with a single dynamically allocated handle and no per-node heap usage.
10. Built-in statistics for queue overflow errors and task stack watermark.
## Using
@@ -39,6 +40,7 @@ In the application, add the component:
#include "zh_encoder.h" // https://github.com/aZholtikov/zh_encoder
#define ENCODER_NUMBER 0x01
#define MENU_VIEWPORT_ROWS 2 // How many item rows fit on the screen (header not counted)
zh_encoder_handle_t *encoder_handle = NULL;
zh_menu_handle_t *menu_handle = NULL;
@@ -118,7 +120,8 @@ void app_main(void)
menu_config.stack_size = configMINIMAL_STACK_SIZE;
menu_config.queue_size = 5;
menu_config.root = &main_menu;
menu_config.loop = true;
menu_config.loop = false;
menu_config.viewport_rows = MENU_VIEWPORT_ROWS;
menu_config.enter_cb = &zh_menu_enter_main_menu_callback; // Optional.
menu_config.exit_cb = &zh_menu_exit_main_menu_callback; // Optional.
menu_config.change_cb = &zh_menu_change_menu_callback;
@@ -173,12 +176,24 @@ void zh_menu_exit_main_menu_callback(void *arg)
void zh_menu_change_menu_callback(zh_menu_node_t *current_node, uint8_t current_index)
{
zh_menu_view_t view = {0};
zh_menu_get_view(&menu_handle, &view);
printf("\n========== MENU ==========\n");
printf("Menu: %s\n", current_node->label);
printf("--------------------------\n");
for (uint8_t i = 0; i < current_node->item_numbers; ++i)
for (uint8_t i = 0; i < view.visible_count; ++i)
{
printf("%c %s\n", i == current_index ? '>' : ' ', current_node->submenu[i].label);
uint8_t idx = view.first_visible + i;
printf("%c %s", (idx == current_index) ? '>' : ' ', current_node->submenu[idx].label);
if (i == 0 && view.first_visible > 0)
{
printf(" ↑");
}
if ((i == (uint8_t)(view.visible_count - 1)) && ((uint8_t)(view.first_visible + view.visible_count) < current_node->item_numbers))
{
printf(" ↓");
}
printf("\n");
}
printf("==========================\n\n");
fflush(stdout);
+35 -1
View File
@@ -21,6 +21,7 @@
* - Configurable callbacks for position change, enter and exit
* - Cyclic navigation mode over menu items
* - Flexible handling of selectable and non-selectable items
* - Viewport scrolling with "sticky" selected item and `↑`/`↓` indicators
* - Low memory footprint due to dynamic allocation of the handle
*
* @note Internal state is protected by a spinlock. Callbacks are invoked
@@ -41,7 +42,8 @@
{ \
.task_priority = 1, \
.stack_size = configMINIMAL_STACK_SIZE, \
.queue_size = 1}
.queue_size = 1, \
.viewport_rows = 0}
#ifdef __cplusplus
extern "C"
@@ -109,6 +111,20 @@ extern "C"
bool is_selected; /*!< Flag indicating that the current item is in the selection state */
} zh_menu_status_t;
/**
* @brief Visible window of the current menu level.
*
* Returned by zh_menu_get_view(). Use it in change_cb to render only
* the part of the menu that fits on the screen. The header (the label
* of the current menu node) is always rendered separately and is not
* part of the window.
*/
typedef struct
{
uint8_t first_visible; /*!< Index of the first row to display */
uint8_t visible_count; /*!< Number of rows to display (may be less than viewport_rows) */
} zh_menu_view_t;
/**
* @brief Menu initialization configuration.
*
@@ -127,6 +143,7 @@ extern "C"
uint16_t stack_size; /*!< Stack size for the shared processing task, minimum configMINIMAL_STACK_SIZE */
uint8_t task_priority; /*!< Task priority for the shared processing task, minimum value 1 */
uint8_t queue_size; /*!< Queue size for the shared processing queue, minimum value 1 */
uint8_t viewport_rows; /*!< Number of visible rows on the screen (header not counted). 0 = show all rows */
} zh_menu_init_config_t;
typedef struct
@@ -198,6 +215,23 @@ extern "C"
*/
esp_err_t zh_menu_get(zh_menu_handle_t **handle, zh_menu_status_t *status);
/**
* @brief Get the current visible window of the menu.
*
* Returns the first visible row and the number of visible rows on the
* current level. Call it from change_cb to render only the part of the
* menu that fits on the screen. When viewport_rows is 0 or the current
* level has no more items than viewport_rows, first_visible is 0 and
* visible_count equals the number of items on the current level.
*
* @param[in] handle Pointer to the handle pointer (must not be NULL)
* @param[out] view Pointer to the view structure to be filled (must not be NULL)
*
* @return ESP_OK on success
* @return ESP_ERR_INVALID_ARG if handle, *handle or view is NULL
*/
esp_err_t zh_menu_get_view(zh_menu_handle_t **handle, zh_menu_view_t *view);
/**
* @brief Retrieve statistics about the menu.
*
+1 -1
View File
@@ -1 +1 @@
2.0.0
2.1.0
+81 -8
View File
@@ -13,8 +13,9 @@
3. **Настраиваемые обратные вызовы** — `enter_cb`, `exit_cb` и `change_cb` для реакции на вход в меню, выход из него и изменение текущей позиции.
4. **Выбираемые и простые элементы** — флаг `selectable` разделяет элементы, входящие в режим ручной корректировки, и элементы с одноразовым действием.
5. **Циклическая навигация** — опциональный режим зацикливания при перемещении `UP` / `DOWN` по элементам.
6. **Минимальный расход памяти** — динамически выделяется только один хэндл; узлы меню (`zh_menu_node_t`) не копируются и используют пользовательскую память.
7. **Встроенная статистика** — счётчик переполнений очереди обработки и watermark свободного стека задачи.
6. **Viewport scrolling** — при заданном `viewport_rows` отображается только часть элементов; выделенный элемент всегда виден, индикаторы `↑` / `↓` (определяются пользователем в `change_cb`) показывают скрытые элементы сверху/снизу. Возвращается через `zh_menu_get_view()`.
7. **Минимальный расход памяти** — динамически выделяется только один хэндл; узлы меню (`zh_menu_node_t`) не копируются и используют пользовательскую память.
8. **Встроенная статистика** — счётчик переполнений очереди обработки и watermark свободного стека задачи.
## Внутреннее устройство
@@ -23,7 +24,7 @@
Внутренняя структура хэндла меню (opaque-тип, доступен только через API компонента):
| Поле | Тип | Описание |
|------|-----|----------|
| ------ | ----- | ---------- |
| `menu_stack` | `zh_menu_node_t *[ZH_MENU_MAX_DEPTH]` | Стек пройденных уровней меню для навигации `ZH_MENU_BACK` |
| `current_menu` | `zh_menu_node_t *` | Указатель на текущий активный уровень меню |
| `root` | `zh_menu_node_t *` | Корневой узел дерева меню, установленный при инициализации |
@@ -33,6 +34,8 @@
| `loop` | `bool` | Флаг циклической навигации по элементам |
| `menu_depth` | `uint8_t` | Текущая глубина вложенности (0 — корневой уровень) |
| `current_index` | `uint8_t` | Индекс выделенного элемента на текущем уровне |
| `viewport_rows` | `uint8_t` | Количество видимых строк на экране (0 = показать все) |
| `scroll_offset` | `uint8_t` | Индекс первой видимой строки в окне прокрутки |
| `is_activated` | `bool` | Флаг активации меню (true после `ZH_MENU_ENTER`) |
| `is_selected` | `bool` | Флаг состояния выбора (true — режим корректировки выбираемого элемента) |
| `lock` | `portMUX_TYPE` | Спинлок для защиты полей от одновременного доступа |
@@ -42,7 +45,7 @@
Описывает один элемент меню, определённый пользователем в статическом массиве:
| Поле | Тип | Описание |
|------|-----|----------|
| ------ | ----- | ---------- |
| `label` | `const char *` | Отображаемое имя элемента |
| `submenu` | `struct zh_menu_node_t *` | Указатель на массив вложенных элементов или `NULL` |
| `item_numbers` | `uint8_t` | Количество элементов в массиве `submenu` |
@@ -56,7 +59,7 @@
Передаётся в `zh_menu_init()` для настройки меню:
| Поле | Тип | Описание |
|------|-----|----------|
| ------ | ----- | ---------- |
| `root` | `zh_menu_node_t *` | Корневой узел дерева меню (must not be NULL) |
| `change_cb` | `void (*)(zh_menu_node_t *, uint8_t)` | Колбэк изменения позиции (может быть NULL) |
| `enter_cb` | `void (*)(void *)` | Колбэк входа в меню (может быть NULL) |
@@ -65,6 +68,7 @@
| `stack_size` | `uint16_t` | Размер стека задачи обработки (минимум `configMINIMAL_STACK_SIZE`) |
| `task_priority` | `uint8_t` | Приоритет задачи обработки (минимум 1) |
| `queue_size` | `uint8_t` | Размер очереди команд (минимум 1) |
| `viewport_rows` | `uint8_t` | Количество видимых строк на экране (0 = показать все) |
### Статистика `zh_menu_stats_t`
@@ -75,6 +79,17 @@
| `queue_overflow_error` | `uint32_t` | Счётчик переполнений очереди команд |
| `min_stack_size` | `uint32_t` | Минимальный остаток свободного стека задачи (watermark) |
### Видимое окно меню `zh_menu_view_t`
Возвращается `zh_menu_get_view()` для рендеринга только той части меню, которая помещается на экране:
| Поле | Тип | Описание |
|------|-----|----------|
| `first_visible` | `uint8_t` | Индекс первой строки для отображения |
| `visible_count` | `uint8_t` | Количество строк для отображения (может быть меньше `viewport_rows`) |
Заголовок (метка текущего узла меню) всегда отображается отдельно и не входит в окно прокрутки.
### Задача обработки и очередь
Компонент создаёт фоновую задачу FreeRTOS (`zh_menu`) и очередь сообщений (`_queue_handle`), которые разделяются между всеми инициализированными меню. Параметры (приоритет, размер стека, размер очереди) берутся из конфигурации **первого** инициализированного меню и применяются ко всем последующим. При деинициализации последнего меню задача и очередь удаляются.
@@ -82,7 +97,7 @@
Очередь передаёт команды навигации из контекста вызова публичных функций в фоновую задачу:
| Поле | Тип | Описание |
|------|-----|----------|
| ------ | ----- | ---------- |
| `command` | `zh_menu_navigate_t` | Команда навигации |
| `handle` | `zh_menu_handle_t *` | Указатель на хэндл меню |
| `arg_size` | `uint32_t` | Размер аргумента в байтах |
@@ -108,6 +123,7 @@
| Функция | Описание |
|---------|----------|
| `zh_menu_get()` | Заполняет структуру `zh_menu_status_t` флагами активности меню и состояния выбора текущего элемента. |
| `zh_menu_get_view()` | Возвращает видимое окно текущего уровня меню (`zh_menu_view_t`): индекс первой строки и количество отображаемых строк. Вызывайте из `change_cb` для рендеринга только той части меню, которая помещается на экране. |
### Статистика
@@ -123,10 +139,11 @@
| `zh_menu_handle_t` | Opaque-хэндл меню, представляющий состояние меню. Все операции выполняются через указатель на этот тип. |
| `zh_menu_navigate_t` | Перечисление команд навигации: `ZH_MENU_ENTER`, `ZH_MENU_SHOW`, `ZH_MENU_UP`, `ZH_MENU_DOWN`, `ZH_MENU_SELECT`, `ZH_MENU_BACK`, `ZH_MENU_MAX`. |
| `zh_menu_node_t` | Узел дерева меню, определённый пользователем в статическом массиве. |
| `zh_menu_init_config_t` | Конфигурация инициализации: корневой узел, колбэки, флаг циклической навигации, параметры задачи. |
| `zh_menu_init_config_t` | Конфигурация инициализации: корневой узел, колбэки, флаг циклической навигации, параметры задачи, размер viewport. |
| `zh_menu_status_t` | Текущее состояние меню: `is_active` и `is_selected`. |
| `zh_menu_view_t` | Видимое окно текущего уровня меню: `first_visible` и `visible_count`. |
| `zh_menu_stats_t` | Статистика: `queue_overflow_error` и `min_stack_size`. |
| `ZH_MENU_INIT_CONFIG_DEFAULT()` | Макрос для инициализации конфигурации по умолчанию: приоритет 1, стек `configMINIMAL_STACK_SIZE`, размер очереди 1. |
| `ZH_MENU_INIT_CONFIG_DEFAULT()` | Макрос для инициализации конфигурации по умолчанию: приоритет 1, стек `configMINIMAL_STACK_SIZE`, размер очереди 1, `viewport_rows = 0` (показать все). |
## Потокобезопасность
@@ -139,6 +156,18 @@
## Алгоритмы
### Viewport Scrolling
Когда `viewport_rows > 0` и количество элементов на текущем уровне превышает `viewport_rows`, включается прокрутка:
1. **Инициализация** — при `ZH_MENU_ENTER`, входе в подменю, возврате `ZH_MENU_BACK` и деактивации `scroll_offset` сбрасывается в 0.
2. **Корректировка окна** — при каждом изменении `current_index` (команды `UP` / `DOWN`) вызывается `_zh_menu_adjust_scroll()`:
- Если выделенный элемент выше видимого окна (`current_index < scroll_offset`), окно сдвигается вверх: `scroll_offset = current_index`.
- Если выделенный элемент ниже видимого окна (`current_index >= scroll_offset + viewport_rows`), окно сдвигается вниз: `scroll_offset = current_index - viewport_rows + 1`.
- Окно не может выходить за пределы списка: `scroll_offset` не может быть больше `total - viewport_rows`.
3. **Получение окна** — `zh_menu_get_view()` возвращает `first_visible = scroll_offset` и `visible_count` — количество строк, помещающихся в окне (не больше `viewport_rows` и не больше оставшихся элементов).
4. **Рендеринг** — пользовательский код в `change_cb` вызывает `zh_menu_get_view()` и отображает только видимые строки. Индикаторы `↑` / `↓` определяются пользователем: `↑` — если `scroll_offset > 0`, `↓` — если `first_visible + visible_count < total`.
### Навигация по уровням меню
При каждой команде навигации фоновая задача выполняет следующие шаги:
@@ -353,6 +382,49 @@ void app_main(void)
}
```
### Viewport Scrolling
```c
#include "zh_menu.h"
zh_menu_handle_t *menu_handle = NULL;
void zh_menu_change_callback(zh_menu_node_t *node, uint8_t index)
{
zh_menu_view_t view = {0};
zh_menu_get_view(&menu_handle, &view);
printf("\n=== %s ===\n", node->label);
// Индикатор прокрутки вверх
if (view.first_visible > 0) {
printf(" ↑\n");
}
// Отображение только видимых строк
for (uint8_t i = 0; i < view.visible_count; ++i) {
uint8_t item_idx = view.first_visible + i;
printf("%c %s\n", item_idx == index ? '>' : ' ', node->submenu[item_idx].label);
}
// Индикатор прокрутки вниз
if (view.first_visible + view.visible_count < node->item_numbers) {
printf(" ↓\n");
}
fflush(stdout);
}
void app_main(void)
{
zh_menu_init_config_t menu_config = ZH_MENU_INIT_CONFIG_DEFAULT();
menu_config.root = &main_menu;
menu_config.viewport_rows = 5; // Показывать по 5 строк
menu_config.change_cb = &zh_menu_change_callback;
ESP_ERROR_CHECK(zh_menu_init(&menu_config, &menu_handle));
// ...
}
```
## Логирование
Компонент использует ESP-IDF логирование с тегом `"zh_menu"`. Сообщения об ошибках включают имя файла, номер строки и имя кода ошибки.
@@ -380,6 +452,7 @@ esp_log_level_set("zh_menu", ESP_LOG_ERROR);
5. **Колбэки вызываются вне критической секции** — пользовательские функции (`change_cb`, `enter_cb`, `exit_cb`, `item_cb`) не должны выполнять длительные блокирующие операции.
6. **Размер аргумента ограничен 128 байтами** — буфер `arg` в очереди команд имеет фиксированный размер `ZH_MENU_ARG_MAX_SIZE`.
7. **Публичные функции не защищены мьютексом** — их следует вызывать из одной задачи или обеспечивать внешнюю синхронизацию при доступе из нескольких контекстов.
8. **Viewport_rows = 0 означает «показать все»** — при `viewport_rows == 0` прокрутка отключена и `zh_menu_get_view()` возвращает все элементы меню.
## Требования для сборки
+74
View File
@@ -54,6 +54,8 @@ struct _zh_menu_handle_t
bool loop; /*!< Cyclic navigation flag */
uint8_t menu_depth; /*!< Current nesting depth */
uint8_t current_index; /*!< Index of the highlighted item on the current level */
uint8_t viewport_rows; /*!< Number of visible rows (0 = show all) */
uint8_t scroll_offset; /*!< Index of the first visible row */
bool is_activated; /*!< Menu activity flag */
bool is_selected; /*!< Selection state flag of the current item */
portMUX_TYPE lock; /*!< Spinlock protecting fields accessed from ISR and task context */
@@ -128,6 +130,16 @@ static esp_err_t _zh_menu_task_init(const zh_menu_init_config_t *config);
*/
static void _zh_menu_rollback_task_and_resources(void);
/**
* @brief Recalculate scroll_offset so that current_index stays in the window.
*
* Called whenever current_index changes, so the selected row is always visible.
* The window "sticks" to the edges only when necessary.
*
* @param handle Pointer to the menu handle (lock must be held by the caller)
*/
static void _zh_menu_adjust_scroll(zh_menu_handle_t *handle);
/**
* @brief Background task that processes menu navigation commands from the queue.
*
@@ -214,6 +226,33 @@ esp_err_t zh_menu_get(zh_menu_handle_t **handle, zh_menu_status_t *status)
return ESP_OK;
}
esp_err_t zh_menu_get_view(zh_menu_handle_t **handle, zh_menu_view_t *view)
{
ZH_LOGI("Menu get view started.");
ZH_ERROR_CHECK(handle != NULL && *handle != NULL && view != NULL, ESP_ERR_INVALID_ARG, NULL, "Menu get view failed. Invalid argument.");
taskENTER_CRITICAL(&(*handle)->lock);
if ((*handle)->current_menu == NULL)
{
view->first_visible = 0;
view->visible_count = 0;
}
else if ((*handle)->viewport_rows == 0 ||
(*handle)->current_menu->item_numbers <= (*handle)->viewport_rows)
{
view->first_visible = 0;
view->visible_count = (*handle)->current_menu->item_numbers;
}
else
{
view->first_visible = (*handle)->scroll_offset;
uint8_t rest = (*handle)->current_menu->item_numbers - (*handle)->scroll_offset;
view->visible_count = (rest > (*handle)->viewport_rows) ? (*handle)->viewport_rows : rest;
}
taskEXIT_CRITICAL(&(*handle)->lock);
ZH_LOGI("Menu get view completed successfully.");
return ESP_OK;
}
const zh_menu_stats_t *zh_menu_get_stats(void)
{
return &_stats;
@@ -238,6 +277,8 @@ static esp_err_t _zh_menu_validate_config(const zh_menu_init_config_t *config, z
handle->current_menu = config->root;
handle->root = config->root;
handle->loop = config->loop;
handle->viewport_rows = config->viewport_rows;
handle->scroll_offset = 0;
return ESP_OK;
}
@@ -278,6 +319,33 @@ static void _zh_menu_rollback_task_and_resources(void)
}
}
static void _zh_menu_adjust_scroll(zh_menu_handle_t *handle)
{
if (handle->current_menu == NULL || handle->viewport_rows == 0)
{
handle->scroll_offset = 0;
return;
}
uint8_t total = handle->current_menu->item_numbers;
if (total <= handle->viewport_rows)
{
handle->scroll_offset = 0;
return;
}
if (handle->current_index < handle->scroll_offset)
{
handle->scroll_offset = handle->current_index;
}
else if (handle->current_index >= (uint8_t)(handle->scroll_offset + handle->viewport_rows))
{
handle->scroll_offset = handle->current_index - handle->viewport_rows + 1;
}
if (handle->scroll_offset > (uint8_t)(total - handle->viewport_rows))
{
handle->scroll_offset = total - handle->viewport_rows;
}
}
static void _zh_menu_isr_processing_task(void *pvParameter)
{
(void)pvParameter;
@@ -302,6 +370,7 @@ static void _zh_menu_isr_processing_task(void *pvParameter)
handle->is_selected = false;
handle->current_menu = handle->root;
handle->current_index = 0;
handle->scroll_offset = 0;
handle->menu_depth = 0;
memset(handle->menu_stack, 0, sizeof(handle->menu_stack));
enter_cb = handle->enter_cb;
@@ -336,6 +405,7 @@ static void _zh_menu_isr_processing_task(void *pvParameter)
{
handle->current_index = handle->current_menu->item_numbers - 1;
}
_zh_menu_adjust_scroll(handle);
if (handle->change_cb != NULL)
{
change_cb = handle->change_cb;
@@ -359,6 +429,7 @@ static void _zh_menu_isr_processing_task(void *pvParameter)
{
handle->current_index = 0;
}
_zh_menu_adjust_scroll(handle);
if (handle->change_cb != NULL)
{
change_cb = handle->change_cb;
@@ -405,6 +476,7 @@ static void _zh_menu_isr_processing_task(void *pvParameter)
++handle->menu_depth;
handle->current_menu = item;
handle->current_index = 0;
handle->scroll_offset = 0;
if (handle->change_cb != NULL)
{
change_cb = handle->change_cb;
@@ -436,6 +508,7 @@ static void _zh_menu_isr_processing_task(void *pvParameter)
handle->current_menu = handle->menu_stack[handle->menu_depth];
handle->menu_stack[handle->menu_depth] = NULL;
handle->current_index = 0;
handle->scroll_offset = 0;
if (handle->change_cb != NULL)
{
change_cb = handle->change_cb;
@@ -449,6 +522,7 @@ static void _zh_menu_isr_processing_task(void *pvParameter)
handle->is_selected = false;
handle->current_menu = handle->root;
handle->current_index = 0;
handle->scroll_offset = 0;
handle->menu_depth = 0;
memset(handle->menu_stack, 0, sizeof(handle->menu_stack));
exit_cb = handle->exit_cb;