diff --git a/README.md b/README.md index 36e245a..4a2af6a 100644 --- a/README.md +++ b/README.md @@ -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); diff --git a/include/zh_menu.h b/include/zh_menu.h index d310a16..8479b1a 100644 --- a/include/zh_menu.h +++ b/include/zh_menu.h @@ -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. * diff --git a/version.txt b/version.txt index 359a5b9..50aea0e 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.0.0 \ No newline at end of file +2.1.0 \ No newline at end of file diff --git a/wiki_ru.md b/wiki_ru.md index 33d0ced..aa0664b 100644 --- a/wiki_ru.md +++ b/wiki_ru.md @@ -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()` возвращает все элементы меню. ## Требования для сборки diff --git a/zh_menu.c b/zh_menu.c index fd74bad..eca94b7 100644 --- a/zh_menu.c +++ b/zh_menu.c @@ -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;