feat(ui-lvgl): European fonts and phone-style keyboard

Fonts: Noto Sans generated with lv_font_conv (ui-lvgl/fonts/generate.sh,
OFL) replaces ASCII-only Montserrat: European Latin (Latin-1, Extended-A/B),
Greek, Cyrillic + supplement, typographic punctuation, currency, LVGL
symbols + globe; 12/14/16/20 px, clock 40 px digits only (RLE, so
LV_USE_FONT_COMPRESSED is on).

Keyboard (ui-lvgl/Keyboard.h): one layout per script (Latin QWERTY,
Cyrillic, Greek) picked by the same keyboard_main/alt_alphabet prefs as
ui-new, globe key to switch, hold a key for its variants in a popup above
it, one-shot shift, symbols page; compose limit counted in UTF-8 bytes.
First Settings screen (Keyboard: Main / Additional).

Long-press variants and UTF-8 case mapping move to ui-core/KeyboardData.h
and are shared with ui-new, whose Hold-Enter accents gain Romanian,
Turkish, Hungarian, Baltic and Icelandic letters (and ș/ț, Ukrainian /
Serbian letters now capitalise).

Verified on L2 hardware.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-25 00:08:15 +02:00
co-authored by Claude Opus 5.5
parent cd926b7e45
commit bf1e6fcaa0
16 changed files with 47899 additions and 75 deletions
+93 -14
View File
@@ -9,6 +9,8 @@
#include "../ui-core/UiCore.h" // shared UI Core (header-only, this TU)
#include "Theme.h"
#include "LvglPort.h"
#include "../ui-core/KeyboardData.h"
#include "Keyboard.h"
// ui-lvgl skeleton (docs/development/ui-core.md, step 5): status bar, home,
// conversation list, contact picker, conversation view with compose. Every
@@ -353,6 +355,7 @@ void UITask::back() {
switch (_screen) {
case SCR_THREAD: showChats(); break;
case SCR_CONTACTS: showChats(); break;
case SCR_SETTINGS: showHome(); break;
case SCR_CHATS: showHome(); break;
default: break;
}
@@ -361,6 +364,24 @@ void UITask::back() {
// ── Home ──────────────────────────────────────────────────────────────────────
static void onOpenChats(lv_event_t* e) { (void)e; s_ui->showChats(); }
static void onOpenSettings(lv_event_t* e) { (void)e; s_ui->showSettings(); }
// Wide home tile: icon + label left, optional value label right.
static lv_obj_t* homeTile(lv_obj_t* parent, const char* text, lv_event_cb_t cb, lv_obj_t** value_out) {
lv_obj_t* tile = lv_button_create(parent);
lv_obj_set_size(tile, LV_PCT(100), 42);
lv_obj_set_style_bg_color(tile, lv_color_hex(theme::SURFACE), 0);
lv_obj_set_style_radius(tile, theme::RADIUS, 0);
lv_obj_set_style_shadow_width(tile, 0, 0);
lv_obj_add_event_cb(tile, cb, LV_EVENT_CLICKED, NULL);
lv_obj_t* tl = label(tile, text, THEME_FONT_TITLE, theme::TEXT);
lv_obj_align(tl, LV_ALIGN_LEFT_MID, theme::PAD, 0);
if (value_out) {
*value_out = label(tile, "", THEME_FONT_TITLE, theme::ACCENT);
lv_obj_align(*value_out, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
}
return tile;
}
void UITask::showHome() {
_screen = SCR_HOME;
@@ -373,21 +394,12 @@ void UITask::buildHome() {
lv_obj_set_flex_align(body, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
_home_clock = label(body, "--:--", THEME_FONT_CLOCK, theme::TEXT);
lv_obj_set_style_pad_top(_home_clock, 10, 0);
_home_date = label(body, "", THEME_FONT_BODY, theme::TEXT_MUTED);
lv_obj_t* name = label(body, the_mesh.getNodeName(), THEME_FONT_BODY, theme::ACCENT);
lv_obj_set_style_pad_bottom(name, 8, 0);
lv_obj_set_style_pad_bottom(name, 4, 0);
lv_obj_t* tile = lv_button_create(body);
lv_obj_set_size(tile, LV_PCT(100), 56);
lv_obj_set_style_bg_color(tile, lv_color_hex(theme::SURFACE), 0);
lv_obj_set_style_radius(tile, theme::RADIUS, 0);
lv_obj_set_style_shadow_width(tile, 0, 0);
lv_obj_add_event_cb(tile, onOpenChats, LV_EVENT_CLICKED, NULL);
lv_obj_t* tl = label(tile, LV_SYMBOL_ENVELOPE " Messages", THEME_FONT_TITLE, theme::TEXT);
lv_obj_align(tl, LV_ALIGN_LEFT_MID, theme::PAD, 0);
_home_unread = label(tile, "", THEME_FONT_TITLE, theme::ACCENT);
lv_obj_align(_home_unread, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
homeTile(body, LV_SYMBOL_ENVELOPE " Messages", onOpenChats, &_home_unread);
homeTile(body, LV_SYMBOL_SETTINGS " Settings", onOpenSettings, NULL);
}
void UITask::refreshHome() {
@@ -547,6 +559,18 @@ static void onKeyboard(lv_event_t* e) {
else if (code == LV_EVENT_CANCEL) lv_obj_add_flag((lv_obj_t*)lv_event_get_user_data(e), LV_OBJ_FLAG_HIDDEN);
}
// Compose limit is in UTF-8 bytes (the over-the-air limit), not characters:
// Cyrillic / Greek / accented letters take two bytes each. Leaves room for the
// "Name: " prefix a channel send adds.
static const size_t COMPOSE_MAX_BYTES = MAX_TEXT_LEN - 40;
static void onComposeInsert(lv_event_t* e) {
lv_obj_t* ta = (lv_obj_t*)lv_event_get_target(e);
const char* ins = (const char*)lv_event_get_param(e);
if (ins && strlen(lv_textarea_get_text(ta)) + strlen(ins) > COMPOSE_MAX_BYTES)
lv_textarea_set_insert_replace(ta, ""); // reject: would exceed the byte limit
}
static void onComposeClicked(lv_event_t* e) {
lv_obj_t* kb = (lv_obj_t*)lv_event_get_user_data(e);
lv_obj_remove_flag(kb, LV_OBJ_FLAG_HIDDEN);
@@ -606,13 +630,13 @@ void UITask::buildThread() {
_compose_ta = lv_textarea_create(bar);
lv_textarea_set_one_line(_compose_ta, true);
lv_textarea_set_placeholder_text(_compose_ta, "Message");
lv_textarea_set_max_length(_compose_ta, MAX_TEXT_LEN - 40);
lv_obj_add_event_cb(_compose_ta, onComposeInsert, LV_EVENT_INSERT, NULL);
lv_obj_set_size(_compose_ta, LV_PCT(100), 36);
lv_obj_align(_compose_ta, LV_ALIGN_LEFT_MID, 0, 0);
// In the body's flex column below the compose bar: showing it shrinks the
// message list, so the text field stays visible just above the keys.
_keyboard = lv_keyboard_create(body);
_keyboard = kb::create(body, _prefs); // phone-style, scripts from prefs, hold for accents (Keyboard.h)
lv_obj_set_size(_keyboard, LV_PCT(100), 130);
lv_keyboard_set_textarea(_keyboard, _compose_ta);
lv_obj_add_event_cb(_keyboard, onKeyboard, LV_EVENT_READY, _keyboard);
@@ -756,3 +780,58 @@ void UITask::sendFromCompose() {
showToast("Send failed");
}
}
// ── Settings (first rows; the declarative schema replaces this later) ────────
static lv_obj_t* s_kb_main_dd = nullptr;
static lv_obj_t* s_kb_alt_dd = nullptr;
static void onKeyboardAlphabet(lv_event_t* e) {
(void)e;
s_ui->setKeyboardAlphabets(lv_dropdown_get_selected(s_kb_main_dd), lv_dropdown_get_selected(s_kb_alt_dd));
}
// One settings row: label left, dropdown right.
static lv_obj_t* dropdownRow(lv_obj_t* parent, const char* text, const char* options, int sel) {
lv_obj_t* row = lv_obj_create(parent);
styleSurface(row, theme::SURFACE);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(row, LV_PCT(100), theme::ROW_H);
lv_obj_set_style_radius(row, theme::RADIUS, 0);
lv_obj_t* l = label(row, text, THEME_FONT_BODY, theme::TEXT);
lv_obj_align(l, LV_ALIGN_LEFT_MID, theme::PAD, 0);
lv_obj_t* dd = lv_dropdown_create(row);
lv_dropdown_set_options_static(dd, options);
lv_dropdown_set_selected(dd, sel);
lv_obj_set_width(dd, 130);
lv_obj_align(dd, LV_ALIGN_RIGHT_MID, -4, 0);
lv_obj_add_event_cb(dd, onKeyboardAlphabet, LV_EVENT_VALUE_CHANGED, NULL);
return dd;
}
void UITask::showSettings() {
_screen = SCR_SETTINGS;
buildSettings();
}
void UITask::buildSettings() {
lv_obj_t* body = newScreen("Settings", true);
sectionTitle(body, "KEYBOARD");
uint8_t main_a = _prefs ? _prefs->keyboard_main_alphabet : 0;
uint8_t alt_a = _prefs ? _prefs->keyboard_alt_alphabet : 0;
if (main_a >= NodePrefs::KB_ALPHABET_COUNT) main_a = 0;
if (alt_a >= NodePrefs::KB_ALPHABET_COUNT) alt_a = main_a;
// Order matches NodePrefs::KB_ALPHABET_* (Latin, Cyrillic, Greek).
s_kb_main_dd = dropdownRow(body, "Main", "Latin\nCyrillic\nGreek", main_a);
// "None" = no second script (stored as alt == main, as ui-new does).
s_kb_alt_dd = dropdownRow(body, "Additional", "None\nLatin\nCyrillic\nGreek",
alt_a == main_a ? 0 : alt_a + 1);
label(body, "Hold a letter for accents and other variants.", THEME_FONT_SMALL, theme::TEXT_MUTED);
}
void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) {
if (!_prefs) return;
_prefs->keyboard_main_alphabet = (uint8_t)main_idx;
_prefs->keyboard_alt_alphabet = (uint8_t)(alt_sel == 0 ? main_idx : alt_sel - 1);
the_mesh.savePrefs();
}