diff --git a/c/menu/README.md b/c/menu/README.md new file mode 100644 index 0000000..3fa43e3 --- /dev/null +++ b/c/menu/README.md @@ -0,0 +1,71 @@ +# menu + +Экранное меню для дисплея и шести кнопок навигации. + +Движок хранит стек открытых экранов, курсор и окно прокрутки, а содержимое +запрашивает у приложения обратными вызовами: так один и тот же экран описывает +и статический список, и перечень датчиков, число которых меняется на ходу. + +Рисует движок через таблицу `Menu_Painter`, поэтому от драйвера дисплея он не +зависит: для ST7789V адаптер занимает несколько строк, для знакосинтезирующего +ЖКИ или SSD1306 — столько же. + +``` + приложение + count/text/enter/adjust ← чем наполнен экран + │ + menu.c стек экранов, курсор, прокрутка, перерисовка + │ + Menu_Painter fill_rect, draw_text + │ + драйвер дисплея +``` + +## Состав + +| Файл | Что делает | Зависимости | +|---|---|---| +| `menu.h`, `menu.c` | стек экранов до 4 уровней, курсор, прокрутка, тема оформления, строка состояния | `stdint.h` | + +Вспомогательное: `Menu_TextCopy()` и `Menu_TextInt()` — форматирование строк +пунктов без `snprintf`. `Menu_GetSnapshot()` отдаёт состояние без отрисовки, +что удобно для хостовых тестов. + +## Что нужно от платформы + +Только два вызова отрисовки — цвет передаётся как есть, поэтому в него +укладывается и RGB565, и палитра монохромного индикатора: + +```c +void fill_rect(void *ctx, int16_t x, int16_t y, uint16_t w, uint16_t h, uint32_t color); +void draw_text(void *ctx, int16_t x, int16_t y, const char *text, + uint32_t color, uint32_t background, uint8_t scale); +``` + +## Быстрый старт + +```c +static const Menu_Screen screen_root = { + .title = "Главное меню", + .count = root_count, /* сколько пунктов сейчас */ + .text = root_text, /* текст пункта по индексу */ + .enter = root_enter, /* вернуть вложенный экран или 0 */ + .context = &app, +}; + +Menu menu; +Menu_Theme theme; +Menu_ThemeDefault(&theme); +Menu_Init(&menu, &painter, &theme, &screen_root); + +Menu_HandleKey(&menu, MENU_KEY_DOWN); +Menu_Render(&menu, 0); /* перерисует только если что-то изменилось */ +``` + +Данные, меняющиеся сами по себе (температура, состояние связи), обновляются +вызовом `Menu_Invalidate()` — движок перерисует экран на ближайшем `Menu_Render()`. + +## Проверено в проектах + +`KONOR_ds18b20`, `OpticalTester` — поверх [`st7789`](../st7789) и [`keypad`](../keypad). +Меню от них не зависит: связывает всё адаптер в приложении. diff --git a/c/menu/menu.c b/c/menu/menu.c new file mode 100644 index 0000000..1453759 --- /dev/null +++ b/c/menu/menu.c @@ -0,0 +1,739 @@ +/** + * @file menu.c + * @brief Навигация по экранам меню и их отрисовка через Menu_Painter. + * + * Экран рисуется целиком: верхняя полоса с заголовком, окно пунктов и нижняя + * строка состояния. Перерисовка выполняется только по признаку dirty, поэтому + * обмен с дисплеем не занимает главный цикл между нажатиями. + */ + +#include "menu.h" + +/** @brief Тёмная тема в формате RGB565. */ +#define MENU_COLOR_BACKGROUND 0x0000U /**< Чёрный фон. */ +#define MENU_COLOR_TITLE_FG 0xFFFFU /**< Белый заголовок. */ +#define MENU_COLOR_TITLE_BG 0x001FU /**< Синяя полоса заголовка. */ +#define MENU_COLOR_ITEM_FG 0xC618U /**< Светло-серый текст пункта. */ +#define MENU_COLOR_VALUE_FG 0x07FFU /**< Голубое значение. */ +#define MENU_COLOR_CURSOR_FG 0x0000U /**< Чёрный текст выбранного пункта. */ +#define MENU_COLOR_CURSOR_BG 0xFFE0U /**< Жёлтая подсветка выбора. */ +#define MENU_COLOR_STATUS_FG 0x8410U /**< Серая строка состояния. */ +#define MENU_COLOR_SCROLL_FG 0xFFE0U /**< Жёлтые указатели прокрутки. */ + +/** + * @brief Возвращает число пунктов экрана. + * + * @param menu Состояние меню. + * @param screen Экран. + * @return Число пунктов. + */ +static uint8_t menu_count(const Menu *menu, const Menu_Screen *screen) +{ + if (screen == 0) { + return 0U; + } + if (screen->count != 0) { + return screen->count((screen->context != 0) ? screen->context : menu->context); + } + return screen->item_count; +} + +/** + * @brief Возвращает контекст, с которым вызываются обработчики экрана. + * + * @param menu Состояние меню. + * @param screen Экран. + * @return Контекст экрана либо контекст меню. + */ +static void *menu_context(const Menu *menu, const Menu_Screen *screen) +{ + return (screen->context != 0) ? screen->context : menu->context; +} + +/** + * @brief Высота одной строки списка вместе с просветом. + * + * @param menu Состояние меню. + * @return Высота строки в пикселях. + */ +static uint16_t menu_row_height(const Menu *menu) +{ + return (uint16_t)((menu->painter.char_height * menu->theme.item_scale) + + menu->theme.row_gap); +} + +/** + * @brief Высота верхней полосы с заголовком. + * + * @param menu Состояние меню. + * @return Высота полосы в пикселях. + */ +static uint16_t menu_title_height(const Menu *menu) +{ + return (uint16_t)((menu->painter.char_height * menu->theme.title_scale) + + (menu->theme.padding * 2U)); +} + +/** + * @brief Высота нижней строки состояния. + * + * @param menu Состояние меню. + * @return Высота строки в пикселях. + */ +static uint16_t menu_status_height(const Menu *menu) +{ + return (uint16_t)(menu->painter.char_height + menu->theme.padding); +} + +/** + * @brief Пересчитывает число видимых пунктов под текущее оформление. + * + * @param menu Состояние меню. + */ +static void menu_update_rows(Menu *menu) +{ + const uint16_t reserved = (uint16_t)(menu_title_height(menu) + menu_status_height(menu)); + const uint16_t row = menu_row_height(menu); + uint16_t rows = 0U; + + if ((row != 0U) && (menu->painter.height > reserved)) { + rows = (uint16_t)((menu->painter.height - reserved) / row); + } + if (rows == 0U) { + rows = 1U; + } + if (rows > (uint16_t)MENU_MAX_ROWS) { + rows = (uint16_t)MENU_MAX_ROWS; + } + menu->rows = (uint8_t)rows; +} + +/** + * @brief Приводит курсор и окно прокрутки к допустимым значениям. + * + * @param menu Состояние меню. + */ +static void menu_clamp(Menu *menu) +{ + const uint8_t level = (uint8_t)(menu->depth - 1U); + const uint8_t total = menu_count(menu, menu->stack[level]); + + if (total == 0U) { + menu->cursor[level] = 0U; + menu->first[level] = 0U; + return; + } + if (menu->cursor[level] >= total) { + menu->cursor[level] = (uint8_t)(total - 1U); + } + if (menu->cursor[level] < menu->first[level]) { + menu->first[level] = menu->cursor[level]; + } + if (menu->cursor[level] >= (uint8_t)(menu->first[level] + menu->rows)) { + menu->first[level] = (uint8_t)(menu->cursor[level] - menu->rows + 1U); + } + if (total <= menu->rows) { + menu->first[level] = 0U; + } else if (menu->first[level] > (uint8_t)(total - menu->rows)) { + menu->first[level] = (uint8_t)(total - menu->rows); + } +} + +void Menu_ThemeDefault(Menu_Theme *theme) +{ + if (theme == 0) { + return; + } + theme->background = MENU_COLOR_BACKGROUND; + theme->title_fg = MENU_COLOR_TITLE_FG; + theme->title_bg = MENU_COLOR_TITLE_BG; + theme->item_fg = MENU_COLOR_ITEM_FG; + theme->item_bg = MENU_COLOR_BACKGROUND; + theme->value_fg = MENU_COLOR_VALUE_FG; + theme->cursor_fg = MENU_COLOR_CURSOR_FG; + theme->cursor_bg = MENU_COLOR_CURSOR_BG; + theme->status_fg = MENU_COLOR_STATUS_FG; + theme->status_bg = MENU_COLOR_BACKGROUND; + theme->scroll_fg = MENU_COLOR_SCROLL_FG; + theme->title_scale = 2U; + theme->item_scale = 2U; + theme->padding = 4U; + theme->row_gap = 4U; +} + +uint8_t Menu_Init(Menu *menu, const Menu_Painter *painter, const Menu_Theme *theme, + const Menu_Screen *root, void *context) +{ + uint8_t level; + + if ((menu == 0) || (painter == 0) || (root == 0)) { + return 0U; + } + if ((painter->fill_rect == 0) || (painter->draw_text == 0) + || (painter->char_width == 0U) || (painter->char_height == 0U)) { + return 0U; + } + if (root->label == 0) { + return 0U; + } + + menu->painter = *painter; + if (theme != 0) { + menu->theme = *theme; + } else { + Menu_ThemeDefault(&menu->theme); + } + if (menu->theme.title_scale == 0U) { + menu->theme.title_scale = 1U; + } + if (menu->theme.item_scale == 0U) { + menu->theme.item_scale = 1U; + } + menu->context = context; + for (level = 0U; level < (uint8_t)MENU_MAX_DEPTH; level++) { + menu->stack[level] = 0; + menu->cursor[level] = 0U; + menu->first[level] = 0U; + } + menu->stack[0] = root; + menu->depth = 1U; + menu->status[0] = '\0'; + + /* Кэш пуст: первая отрисовка обязана пройти по всему экрану. */ + menu->cache_screen = 0; + menu->cache_first = 0U; + menu->cache_valid = 0U; + menu->cache_title[0] = '\0'; + menu->cache_status[0] = '\0'; + for (level = 0U; level < (uint8_t)MENU_MAX_ROWS; level++) { + menu->cache_selected[level] = 0U; + menu->cache_label[level][0] = '\0'; + menu->cache_value[level][0] = '\0'; + } + + menu_update_rows(menu); + menu_clamp(menu); + menu->dirty = 1U; + return 1U; +} + +void Menu_Open(Menu *menu, const Menu_Screen *screen) +{ + if ((menu == 0) || (screen == 0) || (screen->label == 0)) { + return; + } + if (menu->depth >= (uint8_t)MENU_MAX_DEPTH) { + return; + } + menu->stack[menu->depth] = screen; + menu->cursor[menu->depth] = 0U; + menu->first[menu->depth] = 0U; + menu->depth++; + menu_clamp(menu); + menu->dirty = 1U; +} + +void Menu_Back(Menu *menu) +{ + if ((menu == 0) || (menu->depth <= 1U)) { + return; + } + menu->depth--; + menu->stack[menu->depth] = 0; + menu_clamp(menu); + menu->dirty = 1U; +} + +void Menu_Home(Menu *menu) +{ + if (menu == 0) { + return; + } + while (menu->depth > 1U) { + menu->depth--; + menu->stack[menu->depth] = 0; + } + menu_clamp(menu); + menu->dirty = 1U; +} + +void Menu_HandleKey(Menu *menu, Menu_Key key) +{ + const Menu_Screen *screen; + uint8_t level; + uint8_t total; + + if ((menu == 0) || (menu->depth == 0U)) { + return; + } + level = (uint8_t)(menu->depth - 1U); + screen = menu->stack[level]; + total = menu_count(menu, screen); + + switch (key) { + case MENU_KEY_UP: + if (total != 0U) { + menu->cursor[level] = (uint8_t)((menu->cursor[level] == 0U) + ? (total - 1U) : (menu->cursor[level] - 1U)); + } + break; + + case MENU_KEY_DOWN: + if (total != 0U) { + menu->cursor[level] = (uint8_t)((menu->cursor[level] >= (total - 1U)) + ? 0U : (menu->cursor[level] + 1U)); + } + break; + + case MENU_KEY_LEFT: + if ((screen->adjust != 0) && (total != 0U)) { + screen->adjust(menu_context(menu, screen), menu->cursor[level], -1); + } else { + Menu_Back(menu); + } + break; + + case MENU_KEY_RIGHT: + if ((screen->adjust != 0) && (total != 0U)) { + screen->adjust(menu_context(menu, screen), menu->cursor[level], 1); + } else if (screen->enter != 0) { + Menu_Open(menu, screen->enter(menu_context(menu, screen), menu->cursor[level])); + } + break; + + case MENU_KEY_ENTER: + if ((screen->enter != 0) && (total != 0U)) { + Menu_Open(menu, screen->enter(menu_context(menu, screen), menu->cursor[level])); + } + break; + + case MENU_KEY_BACK: + default: + Menu_Back(menu); + break; + } + + menu_clamp(menu); + menu->dirty = 1U; +} + +void Menu_Invalidate(Menu *menu) +{ + if (menu != 0) { + menu->dirty = 1U; + } +} + +uint8_t Menu_IsDirty(const Menu *menu) +{ + return (uint8_t)((menu != 0) ? menu->dirty : 0U); +} + +void Menu_SetStatus(Menu *menu, const char *text) +{ + if (menu == 0) { + return; + } + Menu_TextCopy(menu->status, (uint8_t)MENU_TEXT_MAX, text); + menu->dirty = 1U; +} + +const Menu_Screen *Menu_Current(const Menu *menu) +{ + if ((menu == 0) || (menu->depth == 0U)) { + return 0; + } + return menu->stack[menu->depth - 1U]; +} + +uint8_t Menu_Cursor(const Menu *menu) +{ + if ((menu == 0) || (menu->depth == 0U)) { + return 0U; + } + return menu->cursor[menu->depth - 1U]; +} + +uint8_t Menu_GetSnapshot(const Menu *menu, Menu_Snapshot *out) +{ + const Menu_Screen *screen; + uint8_t level; + uint8_t visible; + uint8_t index; + + if ((menu == 0) || (out == 0)) { + return 0U; + } + if ((menu->depth == 0U) || (menu->cache_valid == 0U)) { + return 0U; + } + level = (uint8_t)(menu->depth - 1U); + screen = menu->stack[level]; + + out->title = menu->cache_title; + out->status = menu->cache_status; + out->first = menu->cache_first; + out->total = menu_count(menu, screen); + out->cursor = menu->cursor[level]; + out->depth = menu->depth; + + /* Строк в снимке столько же, сколько занято на панели: окно минус хвост. */ + visible = menu->rows; + if (visible > (uint8_t)MENU_MAX_ROWS) { + visible = (uint8_t)MENU_MAX_ROWS; + } + if (out->total > out->first) { + const uint8_t rest = (uint8_t)(out->total - out->first); + + out->rows = (rest < visible) ? rest : visible; + } else { + out->rows = 0U; + } + + for (index = 0U; index < (uint8_t)MENU_MAX_ROWS; index++) { + if (index < out->rows) { + out->label[index] = menu->cache_label[index]; + out->value[index] = menu->cache_value[index]; + out->selected[index] = menu->cache_selected[index]; + } else { + out->label[index] = ""; + out->value[index] = ""; + out->selected[index] = 0U; + } + } + return 1U; +} + +void Menu_TextCopy(char *out, uint8_t size, const char *text) +{ + uint8_t index = 0U; + + if ((out == 0) || (size == 0U)) { + return; + } + if (text != 0) { + while ((text[index] != '\0') && (index < (uint8_t)(size - 1U))) { + out[index] = text[index]; + index++; + } + } + out[index] = '\0'; +} + +void Menu_TextInt(char *out, uint8_t size, int32_t value, const char *suffix) +{ + char digits[12]; + uint8_t count = 0U; + uint8_t index = 0U; + uint32_t magnitude; + + if ((out == 0) || (size == 0U)) { + return; + } + magnitude = (value < 0) ? (uint32_t)(-value) : (uint32_t)value; + do { + digits[count] = (char)('0' + (magnitude % 10U)); + magnitude /= 10U; + count++; + } while ((magnitude != 0U) && (count < sizeof(digits))); + + if ((value < 0) && (index < (uint8_t)(size - 1U))) { + out[index] = '-'; + index++; + } + while ((count != 0U) && (index < (uint8_t)(size - 1U))) { + count--; + out[index] = digits[count]; + index++; + } + if (suffix != 0) { + uint8_t tail = 0U; + + while ((suffix[tail] != '\0') && (index < (uint8_t)(size - 1U))) { + out[index] = suffix[tail]; + index++; + tail++; + } + } + out[index] = '\0'; +} + +/** + * @brief Рисует верхнюю полосу с заголовком открытого экрана. + * + * @param menu Состояние меню. + * @param screen Открытый экран. + */ +/** + * @brief Сравнивает две строки с ограничением по длине буфера кэша. + * + * @param left Первая строка. + * @param right Вторая строка. + * @return 1, если строки совпадают, иначе 0. + */ +static uint8_t menu_text_equal(const char *left, const char *right) +{ + uint8_t index; + + for (index = 0U; index < (uint8_t)MENU_TEXT_MAX; index++) { + if (left[index] != right[index]) { + return 0U; + } + if (left[index] == '\0') { + return 1U; + } + } + return 1U; +} + +/** + * @brief Копирует строку в буфер кэша с обрезкой по его размеру. + * + * @param destination Буфер кэша длиной MENU_TEXT_MAX. + * @param source Исходная строка. + */ +static void menu_text_remember(char *destination, const char *source) +{ + uint8_t index; + + for (index = 0U; index < (uint8_t)(MENU_TEXT_MAX - 1U); index++) { + destination[index] = source[index]; + if (source[index] == '\0') { + return; + } + } + destination[MENU_TEXT_MAX - 1U] = '\0'; +} + +static void menu_draw_title(Menu *menu, const Menu_Screen *screen, uint8_t force) +{ + const uint16_t height = menu_title_height(menu); + const Menu_Painter *painter = &menu->painter; + const char *const title = (screen->title != 0) ? screen->title : ""; + + /* Заголовок перерисовывается только при смене текста: иначе экран мигает. */ + if ((force == 0U) && (menu_text_equal(menu->cache_title, title) != 0U)) { + return; + } + menu_text_remember(menu->cache_title, title); + + painter->fill_rect(painter->context, 0, 0, painter->width, height, menu->theme.title_bg); + painter->draw_text(painter->context, (int16_t)menu->theme.padding, + (int16_t)menu->theme.padding, + (screen->title != 0) ? screen->title : "", + menu->theme.title_fg, menu->theme.title_bg, menu->theme.title_scale); +} + +/** + * @brief Рисует нижнюю строку состояния и указатели прокрутки. + * + * @param menu Состояние меню. + * @param total Число пунктов экрана. + * @param first Номер верхнего видимого пункта. + */ +static void menu_draw_status(Menu *menu, uint8_t total, uint8_t first, uint8_t force) +{ + const Menu_Painter *painter = &menu->painter; + const uint16_t height = menu_status_height(menu); + const int16_t top = (int16_t)(painter->height - height); + char marks[4]; + char signature[MENU_TEXT_MAX]; + uint8_t index = 0U; + uint8_t length; + + /* Список длиннее окна: показываем, в какую сторону есть скрытые пункты. */ + if (total > menu->rows) { + if (first != 0U) { + marks[index] = '^'; + index++; + } + if ((uint8_t)(first + menu->rows) < total) { + marks[index] = 'v'; + index++; + } + } + marks[index] = '\0'; + + /* Подпись объединяет текст и указатели, поэтому сравнение одно на всё. */ + menu_text_remember(signature, menu->status); + length = 0U; + while ((length < (uint8_t)(MENU_TEXT_MAX - 1U)) && (signature[length] != '\0')) { + length++; + } + if ((uint8_t)(length + index) < (uint8_t)(MENU_TEXT_MAX - 1U)) { + uint8_t mark; + + for (mark = 0U; mark < index; mark++) { + signature[length] = marks[mark]; + length++; + } + signature[length] = '\0'; + } + if ((force == 0U) && (menu_text_equal(menu->cache_status, signature) != 0U)) { + return; + } + menu_text_remember(menu->cache_status, signature); + + painter->fill_rect(painter->context, 0, top, painter->width, height, menu->theme.status_bg); + painter->draw_text(painter->context, (int16_t)menu->theme.padding, + (int16_t)(top + (int16_t)(menu->theme.padding / 2U)), + menu->status, menu->theme.status_fg, menu->theme.status_bg, 1U); + + if (index != 0U) { + const int16_t x = (int16_t)(painter->width - menu->theme.padding + - (index * painter->char_width)); + + painter->draw_text(painter->context, x, + (int16_t)(top + (int16_t)(menu->theme.padding / 2U)), marks, + menu->theme.scroll_fg, menu->theme.status_bg, 1U); + } +} + +/** + * @brief Рисует одну строку списка: название слева, значение справа. + * + * @param menu Состояние меню. + * @param screen Открытый экран. + * @param item Номер пункта. + * @param top Верхняя граница строки. + * @param selected 1, если пункт выбран курсором. + */ +static void menu_draw_row(Menu *menu, const Menu_Screen *screen, uint8_t item, + int16_t top, uint8_t selected, uint8_t slot, uint8_t force) +{ + const Menu_Painter *painter = &menu->painter; + const uint16_t row = menu_row_height(menu); + const uint16_t step = (uint16_t)(painter->char_width * menu->theme.item_scale); + const uint32_t foreground = (selected != 0U) ? menu->theme.cursor_fg : menu->theme.item_fg; + const uint32_t background = (selected != 0U) ? menu->theme.cursor_bg : menu->theme.item_bg; + const uint32_t value_fg = (selected != 0U) ? menu->theme.cursor_fg : menu->theme.value_fg; + char label[MENU_TEXT_MAX]; + char value[MENU_TEXT_MAX]; + uint8_t label_room; + uint8_t value_length = 0U; + int16_t x; + + label[0] = '\0'; + screen->label(menu_context(menu, screen), item, label, (uint8_t)MENU_TEXT_MAX); + value[0] = '\0'; + if (screen->value != 0) { + screen->value(menu_context(menu, screen), item, value, (uint8_t)MENU_TEXT_MAX); + while (value[value_length] != '\0') { + value_length++; + } + } + + /* Название обрезается так, чтобы значение справа осталось целиком. */ + if (step != 0U) { + const uint16_t columns = (uint16_t)((painter->width - (menu->theme.padding * 2U)) / step); + + label_room = (uint8_t)((columns > (value_length + 1U)) + ? (columns - value_length - 1U) : 1U); + if (label_room > (uint8_t)(MENU_TEXT_MAX - 1U)) { + label_room = (uint8_t)(MENU_TEXT_MAX - 1U); + } + label[label_room] = '\0'; + } + + /* Совпадение с прошлой отрисовкой означает, что строку трогать не нужно. */ + if ((force == 0U) && (slot < (uint8_t)MENU_MAX_ROWS) + && (menu->cache_selected[slot] == selected) + && (menu_text_equal(menu->cache_label[slot], label) != 0U) + && (menu_text_equal(menu->cache_value[slot], value) != 0U)) { + return; + } + if (slot < (uint8_t)MENU_MAX_ROWS) { + menu->cache_selected[slot] = selected; + menu_text_remember(menu->cache_label[slot], label); + menu_text_remember(menu->cache_value[slot], value); + } + + painter->fill_rect(painter->context, 0, top, painter->width, row, background); + painter->draw_text(painter->context, (int16_t)menu->theme.padding, + (int16_t)(top + (int16_t)(menu->theme.row_gap / 2U)), label, + foreground, background, menu->theme.item_scale); + if (value_length != 0U) { + x = (int16_t)(painter->width - menu->theme.padding - (value_length * step)); + painter->draw_text(painter->context, x, + (int16_t)(top + (int16_t)(menu->theme.row_gap / 2U)), value, + value_fg, background, menu->theme.item_scale); + } +} + +void Menu_Render(Menu *menu, uint8_t force) +{ + const Menu_Screen *screen; + const Menu_Painter *painter; + uint8_t level; + uint8_t total; + uint8_t first; + uint8_t index; + uint8_t full; + uint16_t row; + int16_t top; + int16_t list_bottom; + + if ((menu == 0) || (menu->depth == 0U)) { + return; + } + if ((menu->dirty == 0U) && (force == 0U)) { + return; + } + + painter = &menu->painter; + level = (uint8_t)(menu->depth - 1U); + screen = menu->stack[level]; + menu_update_rows(menu); + menu_clamp(menu); + total = menu_count(menu, screen); + first = menu->first[level]; + row = menu_row_height(menu); + top = (int16_t)menu_title_height(menu); + list_bottom = (int16_t)(painter->height - menu_status_height(menu)); + + /* + * Полная отрисовка нужна только при смене экрана, прокрутке списка или по + * явному запросу. В остальных случаях перерисовываются лишь те строки, + * содержимое которых изменилось, поэтому обновление данных не мигает. + */ + full = (uint8_t)(((force != 0U) || (menu->cache_valid == 0U) + || (menu->cache_screen != screen) || (menu->cache_first != first)) + ? 1U : 0U); + + menu_draw_title(menu, screen, full); + for (index = 0U; index < menu->rows; index++) { + const uint8_t item = (uint8_t)(first + index); + + if ((top + (int16_t)row) > list_bottom) { + break; + } + if (item < total) { + menu_draw_row(menu, screen, item, top, + (uint8_t)((item == menu->cursor[level]) ? 1U : 0U), index, full); + } else if ((full != 0U) || (index >= (uint8_t)MENU_MAX_ROWS) + || (menu->cache_label[index][0] != 0) + || (menu->cache_value[index][0] != 0)) { + /* Строка опустела: гасим её и запоминаем пустое содержимое. */ + painter->fill_rect(painter->context, 0, top, painter->width, row, + menu->theme.background); + if (index < (uint8_t)MENU_MAX_ROWS) { + menu->cache_label[index][0] = 0; + menu->cache_value[index][0] = 0; + menu->cache_selected[index] = 0U; + } + } else { + /* Пустая строка уже погашена в прошлый раз. */ + } + top = (int16_t)(top + (int16_t)row); + } + if ((full != 0U) && (top < list_bottom)) { + painter->fill_rect(painter->context, 0, top, painter->width, + (uint16_t)(list_bottom - top), menu->theme.background); + } + menu_draw_status(menu, total, first, full); + + menu->cache_screen = screen; + menu->cache_first = first; + menu->cache_valid = 1U; + menu->dirty = 0U; +} diff --git a/c/menu/menu.h b/c/menu/menu.h new file mode 100644 index 0000000..d1bdc3a --- /dev/null +++ b/c/menu/menu.h @@ -0,0 +1,317 @@ +/** + * @file menu.h + * @brief Портируемое меню для дисплея и шести кнопок навигации. + * + * Движок хранит стек открытых экранов, курсор и окно прокрутки, а содержимое + * запрашивает у приложения обратными вызовами: так один и тот же экран описывает + * и статический список, и перечень датчиков, число которых меняется на ходу. + * + * Рисует движок через таблицу Menu_Painter, поэтому от драйвера дисплея он не + * зависит: для ST7789V адаптер занимает несколько строк, для знакосинтезирующего + * ЖКИ или SSD1306 — столько же. + */ + +#ifndef MENU_H +#define MENU_H + +#include + +/** Предельная глубина вложенности экранов. */ +#define MENU_MAX_DEPTH 4U + +/** Размер буфера строки пункта вместе с завершающим нулём. */ +#define MENU_TEXT_MAX 32U + +/** Предельное число одновременно видимых пунктов; ограничивает кэш строк. */ +#define MENU_MAX_ROWS 20U + +/** @brief Кнопки, которые понимает движок меню. */ +typedef enum { + MENU_KEY_UP = 0, /**< Предыдущий пункт. */ + MENU_KEY_DOWN, /**< Следующий пункт. */ + MENU_KEY_LEFT, /**< Уменьшить значение пункта. */ + MENU_KEY_RIGHT, /**< Увеличить значение пункта. */ + MENU_KEY_ENTER, /**< Войти в пункт или подтвердить. */ + MENU_KEY_BACK /**< Вернуться на предыдущий экран. */ +} Menu_Key; + +/** @brief Экран меню; описание неизменно и хранится во флеш-памяти. */ +typedef struct Menu_Screen Menu_Screen; + +/** + * @brief Сообщает текущее число пунктов экрана. + * + * @param context Контекст экрана либо меню. + * @return Число пунктов; ноль допустим. + */ +typedef uint8_t (*Menu_CountFn)(void *context); + +/** + * @brief Формирует текст пункта или его значения. + * + * @param context Контекст экрана либо меню. + * @param index Номер пункта. + * @param out Буфер приёмника, завершается нулём. + * @param size Размер буфера в байтах. + */ +typedef void (*Menu_TextFn)(void *context, uint8_t index, char *out, uint8_t size); + +/** + * @brief Обрабатывает нажатие ввода на пункте. + * + * @param context Контекст экрана либо меню. + * @param index Номер пункта. + * @return Экран для открытия либо 0, если пункт лишь выполняет действие. + */ +typedef const Menu_Screen *(*Menu_EnterFn)(void *context, uint8_t index); + +/** + * @brief Меняет значение пункта кнопками влево и вправо. + * + * @param context Контекст экрана либо меню. + * @param index Номер пункта. + * @param delta Шаг: -1 для влево, +1 для вправо. + */ +typedef void (*Menu_AdjustFn)(void *context, uint8_t index, int8_t delta); + +/** + * @brief Описание экрана меню. + * + * Обязательны заголовок и обратный вызов @c label. Число пунктов берётся из + * @c count, а при нулевом указателе — из поля @c item_count. + */ +struct Menu_Screen { + const char *title; /**< Заголовок в верхней полосе. */ + uint8_t item_count; /**< Число пунктов статического экрана. */ + Menu_CountFn count; /**< Число пунктов динамического экрана либо 0. */ + Menu_TextFn label; /**< Текст пункта; обязателен. */ + Menu_TextFn value; /**< Текст значения справа либо 0. */ + Menu_EnterFn enter; /**< Реакция на ввод либо 0. */ + Menu_AdjustFn adjust; /**< Реакция на влево и вправо либо 0. */ + void *context; /**< Контекст экрана; 0 — использовать контекст меню. */ +}; + +/** + * @brief Обратные вызовы отрисовки и метрика шрифта. + * + * Цвета передаются как есть, поэтому в них укладывается и RGB565, и палитра + * монохромного индикатора. + */ +typedef struct { + /** Заливает прямоугольник цветом. */ + void (*fill_rect)(void *context, int16_t x, int16_t y, uint16_t width, + uint16_t height, uint32_t color); + /** Выводит строку с непрозрачным фоном заданным масштабом шрифта. */ + void (*draw_text)(void *context, int16_t x, int16_t y, const char *text, + uint32_t color, uint32_t background, uint8_t scale); + void *context; /**< Указатель адаптера дисплея. */ + uint16_t width; /**< Ширина области вывода, пикселей. */ + uint16_t height; /**< Высота области вывода, пикселей. */ + uint8_t char_width; /**< Шаг знакоместа при масштабе 1. */ + uint8_t char_height; /**< Высота знакоместа при масштабе 1. */ +} Menu_Painter; + +/** @brief Цвета и отступы оформления. */ +typedef struct { + uint32_t background; /**< Фон рабочей области. */ + uint32_t title_fg; /**< Текст заголовка. */ + uint32_t title_bg; /**< Фон заголовка. */ + uint32_t item_fg; /**< Текст обычного пункта. */ + uint32_t item_bg; /**< Фон обычного пункта. */ + uint32_t value_fg; /**< Текст значения обычного пункта. */ + uint32_t cursor_fg; /**< Текст выбранного пункта. */ + uint32_t cursor_bg; /**< Фон выбранного пункта. */ + uint32_t status_fg; /**< Текст нижней строки состояния. */ + uint32_t status_bg; /**< Фон нижней строки состояния. */ + uint32_t scroll_fg; /**< Указатели прокрутки. */ + uint8_t title_scale; /**< Масштаб шрифта заголовка. */ + uint8_t item_scale; /**< Масштаб шрифта пунктов. */ + uint8_t padding; /**< Отступ от краёв, пикселей. */ + uint8_t row_gap; /**< Просвет между пунктами, пикселей. */ +} Menu_Theme; + +/** @brief Состояние меню; принадлежит вызывающему коду. */ +typedef struct { + const Menu_Screen *stack[MENU_MAX_DEPTH]; /**< Стек открытых экранов. */ + uint8_t cursor[MENU_MAX_DEPTH]; /**< Выбранный пункт по уровням. */ + uint8_t first[MENU_MAX_DEPTH]; /**< Верхний видимый пункт. */ + uint8_t depth; /**< Число экранов в стеке. */ + Menu_Painter painter; /**< Обратные вызовы отрисовки. */ + Menu_Theme theme; /**< Оформление. */ + void *context; /**< Контекст приложения. */ + uint8_t rows; /**< Сколько пунктов видно сразу. */ + uint8_t dirty; /**< 1 — экран требует перерисовки. */ + char status[MENU_TEXT_MAX]; /**< Нижняя строка состояния. */ + /* + * Кэш последней отрисовки. Перерисовываются только изменившиеся строки, + * поэтому обновление данных не вызывает мигания всего экрана. + */ + const Menu_Screen *cache_screen; /**< Экран, для которого кэш верен. */ + uint8_t cache_first; /**< Верхний видимый пункт в кэше. */ + uint8_t cache_valid; /**< 1 — кэш содержит отрисованное. */ + uint8_t cache_selected[MENU_MAX_ROWS]; /**< Признак курсора по строкам. */ + char cache_label[MENU_MAX_ROWS][MENU_TEXT_MAX]; /**< Названия строк. */ + char cache_value[MENU_MAX_ROWS][MENU_TEXT_MAX]; /**< Значения строк. */ + char cache_title[MENU_TEXT_MAX]; /**< Заголовок экрана. */ + char cache_status[MENU_TEXT_MAX]; /**< Строка состояния с указателями. */ +} Menu; + +/** + * @brief Снимок видимой части экрана. + * + * Повторяет то, что сейчас нарисовано на панели: движок отдаёт содержимое из + * кэша последней отрисовки, не обращаясь к обратным вызовам приложения. + * Поэтому снимок дёшев и совпадает с изображением байт в байт. + * + * Строки указывают внутрь состояния меню и действительны до следующего + * вызова Menu_Render. + */ +typedef struct { + const char *title; /**< Заголовок верхней полосы. */ + const char *status; /**< Нижняя строка вместе с указателями прокрутки. */ + const char *label[MENU_MAX_ROWS]; /**< Названия видимых пунктов. */ + const char *value[MENU_MAX_ROWS]; /**< Значения видимых пунктов; пустая строка, если нет. */ + uint8_t selected[MENU_MAX_ROWS]; /**< 1 — на этой строке курсор. */ + uint8_t rows; /**< Число заполненных строк снимка. */ + uint8_t first; /**< Номер верхнего видимого пункта. */ + uint8_t total; /**< Всего пунктов на открытом экране. */ + uint8_t cursor; /**< Выбранный пункт, номер в пределах экрана. */ + uint8_t depth; /**< Глубина стека экранов; 1 — корневой. */ +} Menu_Snapshot; + +/** + * @brief Заполняет оформление тёмной темой для цветного дисплея. + * + * Значения цветов задаются в формате RGB565, отступы рассчитаны на шрифт 6x8. + * + * @param theme Оформление, принадлежащее вызывающему коду. + */ +void Menu_ThemeDefault(Menu_Theme *theme); + +/** + * @brief Готовит меню к работе и открывает корневой экран. + * + * @param menu Состояние меню. + * @param painter Обратные вызовы отрисовки; копируются внутрь состояния. + * @param theme Оформление; при значении 0 берётся Menu_ThemeDefault(). + * @param root Корневой экран. + * @param context Контекст приложения для экранов без своего контекста. + * @return 1 при успешной настройке, 0 при неполных аргументах. + */ +uint8_t Menu_Init(Menu *menu, const Menu_Painter *painter, const Menu_Theme *theme, + const Menu_Screen *root, void *context); + +/** + * @brief Обрабатывает нажатие кнопки навигации. + * + * Перерисовка не выполняется: функция только меняет состояние и поднимает + * признак устаревшего изображения. + * + * @param menu Состояние меню. + * @param key Нажатая кнопка. + */ +void Menu_HandleKey(Menu *menu, Menu_Key key); + +/** + * @brief Открывает вложенный экран, если стек не заполнен. + * + * @param menu Состояние меню. + * @param screen Открываемый экран. + */ +void Menu_Open(Menu *menu, const Menu_Screen *screen); + +/** + * @brief Возвращается на предыдущий экран; на корневом ничего не делает. + * + * @param menu Состояние меню. + */ +void Menu_Back(Menu *menu); + +/** + * @brief Закрывает все вложенные экраны и возвращается к корневому. + * + * @param menu Состояние меню. + */ +void Menu_Home(Menu *menu); + +/** + * @brief Помечает изображение устаревшим после обновления данных. + * + * @param menu Состояние меню. + */ +void Menu_Invalidate(Menu *menu); + +/** + * @brief Сообщает, требуется ли перерисовка. + * + * @param menu Состояние меню. + * @return 1, если изображение устарело. + */ +uint8_t Menu_IsDirty(const Menu *menu); + +/** + * @brief Задаёт текст нижней строки состояния. + * + * @param menu Состояние меню. + * @param text Строка либо 0, чтобы очистить строку. + */ +void Menu_SetStatus(Menu *menu, const char *text); + +/** + * @brief Перерисовывает экран, если изображение устарело. + * + * @param menu Состояние меню. + * @param force 1 — рисовать безусловно. + */ +void Menu_Render(Menu *menu, uint8_t force); + +/** + * @brief Возвращает открытый экран. + * + * @param menu Состояние меню. + * @return Текущий экран либо 0, если меню не настроено. + */ +const Menu_Screen *Menu_Current(const Menu *menu); + +/** + * @brief Возвращает номер выбранного пункта открытого экрана. + * + * @param menu Состояние меню. + * @return Номер пункта; 0 для пустого экрана. + */ +uint8_t Menu_Cursor(const Menu *menu); + +/** + * @brief Снимает видимое содержимое экрана для передачи наружу. + * + * Источник данных — кэш последней отрисовки, поэтому вызывать функцию имеет + * смысл после Menu_Render: до первой отрисовки кэш пуст и снимок не выдаётся. + * + * @param menu Состояние меню. + * @param out Снимок, принадлежащий вызывающему коду. + * @return 1, если снимок заполнен, 0 при пустом кэше или неверных аргументах. + */ +uint8_t Menu_GetSnapshot(const Menu *menu, Menu_Snapshot *out); + +/** + * @brief Копирует строку в буфер пункта с обрезкой по размеру. + * + * Вспомогательная функция для обратных вызовов приложения. + * + * @param out Буфер приёмника. + * @param size Размер буфера вместе с завершающим нулём. + * @param text Копируемая строка либо 0. + */ +void Menu_TextCopy(char *out, uint8_t size, const char *text); + +/** + * @brief Печатает целое со знаком без обращения к stdio. + * + * @param out Буфер приёмника. + * @param size Размер буфера вместе с завершающим нулём. + * @param value Печатаемое значение. + * @param suffix Приписываемая справа строка либо 0. + */ +void Menu_TextInt(char *out, uint8_t size, int32_t value, const char *suffix); + +#endif /* MENU_H */