Files
templates/c/menu/menu.c
Andrey Kruchinkin 1248a6551a feat(menu): экранное меню со стеком экранов и прокруткой
Перенесён из KONOR_ds18b20/lib/menu; в OpticalTester лежала такая же копия.

Содержимое экрана движок запрашивает обратными вызовами, поэтому один
экран описывает и статический список, и перечень датчиков переменной
длины. Рисует через Menu_Painter: от драйвера дисплея не зависит,
цвет передаётся как есть — подходит и RGB565, и монохром.
2026-08-23 01:15:12 +03:00

740 lines
24 KiB
C
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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;
}