/** * @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; }