Поливалка на даче выросла из трёх кнопок в прибор с шестью настройками: порог влажности, интервал полива, дневной и ночной сценарий света, звук предупреждения, уровень в баке. Экран у неё стоял 16x2, и меню пришлось сворачивать в карусель: в кадре один пункт и его значение, а чтобы понять, что идёт соседним, надо было нажать кнопку и посмотреть. Хуже всего было объяснять это маме: «нажми два раза, увидишь порог, потом ещё раз, число начнёт меняться» — инструкция заняла больше места, чем сам скетч. Переезд на экран 20x4 занял один вечер и закрыл вопрос разом: в кадре заголовок и три пункта, соседи видны без нажатий, и прибор читается как записка, приклеенная на стену.
На бумаге разница выглядит скромно: 80 знакомест против 32 — в 2,5 раза больше текста в одном кадре. На деле это разница между интерфейсом-каруселью и настоящим многострочным экраном: у карусели каждое значение добывается нажатием, у окна — взглядом. В этом уроке соберём на 20x4 меню с заголовком, курсором-стрелкой и режимом правки: кнопка листает пункты, тумблер открывает правку, ручка меняет значение, светодиод горит, пока правка открыта. Основа рисования — та же печать по изменению без единого мерцания, что была в мини-меню на 16x2 (/lesson/lcd/lcd-menu-i-kursor/), а новыми будут оконная математика и раскладка строки.
Экран как окно: три пункта в кадре
Экран я раскладываю по жёсткой сетке. Строка 0 — служебная: слева имя прибора, справа текущий режим — SMOTR или PRAVKA. Строки 1–3 — окно меню: три пункта, которые видны в данный момент. Почему три, а не четыре: заголовок с режимом стоит дороже, чем четвёртый пункт. Человек, который не понял, что прибор в режиме правки, крутит ручку и портит настройку; одна служебная строка снимает эту проблему целиком, и я не помню случая, чтобы её не хватало.
Математика окна умещается в две проверки. Курсор ушёл выше окна — окно подтянулось к нему; ушёл ниже — окно поехало за ним. Всё, никакого скролла-анимации и списков строк: перерисовка кадра — это четыре короткие печати по 20 символов, и по шине I2C полный кадр занимает порядка 30–40 мс на все 80 знакомест. Отсюда дисциплина: полный кадр обновляем только при смене пункта или режима, а значения — точечно, только изменившиеся ячейки, иначе мерцание вернётся. У 20x4 есть и сюрприз глубже: строки в памяти дисплея лежат не подряд, мы это разбирали в уроке про адресацию строк (/lesson/lcd/lcd-20x4-adresaciya-strok/) — setCursor всё прячет, но знать, куда пишет библиотека, полезно.
| Зона экрана | Столбцы | Что печатаем |
|---|---|---|
| Строка 0 — служебная | 0–19 | имя прибора слева, режим SMOTR или PRAVKA справа |
| Столбец курсора | 0 | стрелка «>» у активного пункта, пробел у остальных |
| Имя пункта | 1–10 | ровно 10 символов: хвост добиваем пробелами прямо в строке массива |
| Разделитель | 11–12 | двоеточие и пробел — для всех пунктов одинаково |
| Значение | 13–19 | число и хвост из пробелов, затирающий старые цифры |
Правка начинается с отдельного органа управления. Моё мнение с границей: у прибора с тремя и больше кнопками вход в правку — это кнопка выбора; у прибора с двумя органами, как у нашей поливалки, я беру тумблер — переключил и сразу видно по заголовку, в каком ты режиме. Длинное нажатие тоже вариант, но у него подвох: удержание нужно чем-то подтвердить, иначе человек не уверен, сработало ли. Поэтому светодиод: горит — правка открыта, гаснет — значения заморожены.
Внутри правки ручка на A0 через map() переписывает значение активного пункта. Диапазоны у пунктов разные и живут в двух массивах: порог влажности — от 10 до 90 процентов, интервал — от 5 до 120 минут, булевы пункты — 0 и 1, граница приходится на половину оборота ручки. На экране при правке перерисовывается одна зона: столбцы 13–19 активной строки, соседние строки стоят неподвижно. Фронт кнопки ловим по прошлому состоянию плюс окно 200 мс от дребезга — ровно как мы делали в уроке про кнопку и дребезг (/lesson/osnovy/debounce-knopki/).
Собираем интерфейс в симуляторе
Тренажёр собирает тот же конечный автомат целиком. Кнопка на D2 листает шесть пунктов по кругу — следите за Serial: строка punkt=3 okno=1 это момент, когда окно поехало за курсором. Тумблер на D3 открывает правку: заголовок меняет SMOTR на PRAVKA, светодиод на D12 загорается, и ручка A0 начинает двигать значение активного пункта — в Serial идут пары punkt= и v=. Значения, введённые в правке, остаются в массиве после закрытия режима: вернитесь к пункту и убедитесь, что число запомнилось. Имена в массиве выровнены пробелами до десяти символов — поэтому колонка значений стоит ровно, без сдвигов, и затирающий хвост всегда накрывает старое число.
Разбирать поведение по Serial удобно именно так, построчно: каждая строка — одно событие интерфейса. Листание печатает пункт и окно, смена режима — единицу или ноль, правка — пары punkt= и v=. Если соберёте прибор на реальном железе, этот же лог станет журналом отладки: в нём видно, когда кнопка дала дребезг — два пункта за одно нажатие, — когда ручка дрейфует и значения ползут по единице, доезжает ли окно до краёв списка. Я вывожу такие пары в Serial на всех приборах с меню: это дешевле любого отладчика и работает даже тогда, когда под рукой только телефон с USB-кабелем.
const int N_ITEMS = 6; // всего пунктов менюconst int VISIBLE = 3; // сколько влезает под служебной строкой int idx = 0; // активный пункт: 0..N_ITEMS-1int offset = 0; // верхний видимый пункт окна // курсор упёрся в край окна - окно едет за нимvoid followWindow() { if (idx < offset) offset = idx; if (idx > offset + VISIBLE - 1) offset = idx - (VISIBLE - 1);} // по нажатию кнопки: idx = (idx + 1) % N_ITEMS;// followWindow(); drawFrame();Куда это растёт дальше. Подменю — это просто вторая страница: другой массив имён и значений, тот же автомат, тумблер переключает страницы. Настройки стоит сохранять в EEPROM при выходе из правки — значения должны переживать выключение, как хранить и не убивать ячейки, мы разбирали в уроке про первые настройки (/lesson/eeprom/eeprom-pervye-nastroyki/). Контраст у 20x4 выставляется своей ручкой на модуле — на холодной веранде без неё буквы бледнеют (/lesson/lcd/kontrast-lcd-v0-potenciometr/). И последний штрих к оконной математике: она не меняется, когда пунктов становится десять или двадцать. Окно по-прежнему едет только у краёв, а в середине списка курсор ходит по неподвижному кадру — именно поэтому я считаю этот каркас достаточным для любого однораздельного прибора.
Один мостик напоследок. Весь этот интерфейс сидит на библиотеке, которая прячет шину: каждый print() разворачивается в полубайты и стробы, а lcd.init() — в стартовую последовательность из даташита. Если хочется видеть, что именно прячет библиотека — и уметь разговаривать с дисплеем без неё, — следующий урок как раз про это (/lesson/lcd/hd44780-komandy-bez-biblioteki/). Меню от этого не станет другим, но перестанет быть чёрным ящиком.
- Добавьте седьмой пункт «Parol» и вход в правку по двум нажатиям подряд — заодно проверьте, что число пунктов берётся из одной константы.
- Сделайте вторую страницу меню: тумблер переключает список настроек, заголовок и окно переписываются — это готовый каркас подменю.
- Сохраняйте значения в EEPROM при закрытии правки и восстанавливайте в setup() — настройки должны переживать перезапуск.
- Замените кнопку на энкодер EC11: вращение двигает курсор, нажатие входит в правку — followWindow() трогать не придётся.
- Экран 20x4 — 80 знакомест: строка 0 под заголовок с режимом, строки 1–3 — окно из трёх пунктов
- Окно едет за курсором: idx ниже offset — подтянуть сверху, выше offset+2 — снизу
- Полный кадр по I2C — порядка 30–40 мс: кадр только при смене пункта или режима, значения — точечно
- Имена пунктов — одной длины, число пунктов — одна константа: C++ границы массива не проверяет
- Правка на отдельном органе управления: тумблер открывает, LED подтверждает, ручка меняет значение
- 1. Пунктов в меню шесть, в окне видно три. Сколько разных положений у offset — верхнего видимого пункта?
- 2. Курсор стоит на пункте 2, окно — offset 0 (видны пункты 0, 1, 2). Нажали кнопку, курсор переехал на пункт 3. Что сделает followWindow()?
- 3. Зачем значение в правке печатается с хвостом из пробелов?
- 4. Полный кадр 20x4 по шине I2C занимает порядка 30–40 мс. Как это влияет на код интерфейса?
- 5. C++ не проверяет границы массива. Что будет, если цикл отрисовки прочитает names[6] при шести пунктах с номерами 0–5?
Частые вопросы по теме урока
- Пунктов в меню шесть, в окне видно три. Сколько разных положений у offset — верхнего видимого пункта?
- Окно едет, только пока курсор упирается в край: offset меняется от 0 до N_ITEMS − VISIBLE, то есть 0, 1, 2, 3. Четыре: 0, 1, 2 и 3
- Курсор стоит на пункте 2, окно — offset 0 (видны пункты 0, 1, 2). Нажали кнопку, курсор переехал на пункт 3. Что сделает followWindow()?
- Пункт 3 за нижним краем окна (видны 0–2), поэтому окно подтягивается: offset = idx − 2 = 1, и в кадре станут пункты 1, 2, 3. Сдвинет окно на один пункт: offset станет 1
- Зачем значение в правке печатается с хвостом из пробелов?
- Печать кладёт символы поверх старых и ничего не стирает: без хвоста число 100, сменившись на 99, оставило бы прилипший ноль. Пробелы затирают цифры, оставшиеся от прошлого значения
- Полный кадр 20x4 по шине I2C занимает порядка 30–40 мс. Как это влияет на код интерфейса?
- Частые полные перерисовки видны глазу как мерцание и едят время loop(): кадр меняется редко, значения — по факту изменения. Полный кадр — только при смене пункта или режима, значения обновляются точечно
- C++ не проверяет границы массива. Что будет, если цикл отрисовки прочитает names[6] при шести пунктах с номерами 0–5?
- Чтение за границей массива молча возвращает чужие байты памяти — на экране это мусор после последнего пункта. Лечится одной константой числа пунктов. В окне появится мусор из соседних байтов памяти