Files
MeshCore-Solo/examples/companion_radio/ui-lvgl/Keyboard.h
T
JakubandClaude Opus 5.5 87fad71af3 feat(ui-lvgl): 320x240 browser simulator, compose field fixes
Simulator: SIM_UI=lvgl variants/sim/build_wasm.sh builds ui-lvgl for the
browser. SimLcdDisplay blits LVGL's RGB565 flush to a 320x240 canvas and
LvglPort.h's sim branch feeds the mouse in as touch; LVGL comes from the
L2 env's libdeps, its objects cached in web/build/obj_lvgl/. web/lvgl.html
runs it beside a ui-new peer on a direct link, with a window.sim API for
headless checks. New hooks: sim_lcd_touch, sim_lcd_button,
sim_test_send_channel_msg.

Compose: the theme's padding left less than one line in the field, so it
scrolled vertically and text jumped; pad it to fit exactly one line. The
cursor is drawn only while FOCUSED, now set while the keyboard is up.
With the keyboard up the screen header hides so the conversation keeps a
message in view; the accent popup stays inside the keyboard, clear of the
field; channel bubbles put the age next to the sender (one line shorter);
the thread scrollbar shows only while scrolling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 00:36:26 +02:00

269 lines
11 KiB
C++
Raw 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.
#pragma once
// Phone-style on-screen keyboard on top of lv_keyboard (USER_1 mode):
// - one letter layout per script (Latin QWERTY, Cyrillic ЙЦУКЕН, Greek);
// which scripts are offered comes from the same prefs ui-new uses
// (NodePrefs::keyboard_main_alphabet / keyboard_alt_alphabet). The globe
// key switches between the two when they differ.
// - hold a key for its variants (ą ć ę … / ё є ї … / ά έ … / punctuation
// behind '.'), from the shared table in ui-core/KeyboardData.h. The popup
// opens above the key; tap a variant to type it, tap elsewhere to close.
// - one-shot shift; digits / symbols page behind "1#".
// Letters type on release (popover keys), so a long press never types the
// base letter first.
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp only.
namespace kb {
#define KB_BS LV_SYMBOL_BACKSPACE
#define KB_OK LV_SYMBOL_OK
#define KB_SH LV_SYMBOL_UP
static const char* const K_SYM = "1#";
static const char* const K_BACK = "abc";
static const char* const K_GLOBE = "\xEF\x82\xAC"; // FontAwesome globe (U+F0AC) -- see fonts/generate.sh
static const char* const LAT[] = {
"q","w","e","r","t","y","u","i","o","p","\n",
"a","s","d","f","g","h","j","k","l","\n",
KB_SH,"z","x","c","v","b","n","m",KB_BS,"\n",
nullptr };
static const char* const LAT_UP[] = {
"Q","W","E","R","T","Y","U","I","O","P","\n",
"A","S","D","F","G","H","J","K","L","\n",
KB_SH,"Z","X","C","V","B","N","M",KB_BS,"\n",
nullptr };
static const char* const CYR[] = {
"й","ц","у","к","е","н","г","ш","щ","з","х","\n",
"ф","ы","в","а","п","р","о","л","д","ж","э","\n",
KB_SH,"я","ч","с","м","и","т","ь","б","ю",KB_BS,"\n",
nullptr };
static const char* const CYR_UP[] = {
"Й","Ц","У","К","Е","Н","Г","Ш","Щ","З","Х","\n",
"Ф","Ы","В","А","П","Р","О","Л","Д","Ж","Э","\n",
KB_SH,"Я","Ч","С","М","И","Т","Ь","Б","Ю",KB_BS,"\n",
nullptr };
static const char* const GRK[] = {
"ς","ε","ρ","τ","υ","θ","ι","ο","π","\n",
"α","σ","δ","φ","γ","η","ξ","κ","λ","\n",
KB_SH,"ζ","χ","ψ","ω","β","ν","μ",KB_BS,"\n",
nullptr };
static const char* const GRK_UP[] = {
"Σ","Ε","Ρ","Τ","Υ","Θ","Ι","Ο","Π","\n",
"Α","Σ","Δ","Φ","Γ","Η","Ξ","Κ","Λ","\n",
KB_SH,"Ζ","Χ","Ψ","Ω","Β","Ν","Μ",KB_BS,"\n",
nullptr };
static const char* const SYM[] = {
"1","2","3","4","5","6","7","8","9","0","\n",
"-","/",":",";","(",")","€","&","@","\"","\n",
".",",","?","!","'","#","%","+","=",KB_BS,"\n",
nullptr };
// Layout ids; letter layouts in (lower, upper) pairs, in NodePrefs
// KB_ALPHABET_* order (Latin, Cyrillic, Greek) so script * 2 = its lower layout.
enum : uint8_t { L_LAT, L_LAT_UP, L_CYR, L_CYR_UP, L_GRK, L_GRK_UP, L_PAIRED_END = L_GRK_UP,
L_SYM, L_COUNT };
static const char* const* const ROWS[L_COUNT] = { LAT, LAT_UP, CYR, CYR_UP, GRK, GRK_UP, SYM };
// Full lv_keyboard maps: the rows above + a bottom row assembled in
// buildMaps() (globe only when a second script is enabled).
static const int MAX_KEYS = 40;
static const char* s_maps[L_COUNT][MAX_KEYS + 8];
static lv_buttonmatrix_ctrl_t s_ctrl[L_COUNT][MAX_KEYS];
static uint8_t s_layout = L_LAT;
static uint8_t s_script = 0; // current script (NodePrefs KB_ALPHABET_*)
static NodePrefs* s_prefs = nullptr;
static bool s_suppress_release = false; // a long press opened the popup: don't type the base
static lv_obj_t* s_popup = nullptr; // overlay (on lv_layer_top) holding the variants row
static lv_obj_t* s_popup_kbd = nullptr;
static uint8_t mainScript() {
uint8_t s = s_prefs ? s_prefs->keyboard_main_alphabet : 0;
return s < NodePrefs::KB_ALPHABET_COUNT ? s : 0;
}
static uint8_t altScript() {
uint8_t s = s_prefs ? s_prefs->keyboard_alt_alphabet : 0;
return s < NodePrefs::KB_ALPHABET_COUNT ? s : 0;
}
static bool hasAlt() { return altScript() != mainScript(); }
static bool isSpecial(const char* t) {
static const char* const SP[] = { KB_SH, KB_BS, KB_OK, K_SYM, K_BACK, K_GLOBE };
for (const char* s : SP) if (strcmp(t, s) == 0) return true;
return false;
}
// Assemble the maps and derive key widths / styles from the labels: letters
// narrow, typed on release with a magnified popover and no auto-repeat;
// special keys wider and highlighted; space widest.
static void buildMaps() {
bool alt = hasAlt();
for (int l = 0; l < L_COUNT; l++) {
int m = 0;
for (const char* const* p = ROWS[l]; *p; p++) s_maps[l][m++] = *p;
if (l == L_SYM) {
s_maps[l][m++] = K_BACK; s_maps[l][m++] = "_"; s_maps[l][m++] = " ";
s_maps[l][m++] = "*"; s_maps[l][m++] = KB_OK;
} else {
s_maps[l][m++] = K_SYM;
if (alt) s_maps[l][m++] = K_GLOBE;
s_maps[l][m++] = " "; s_maps[l][m++] = "."; s_maps[l][m++] = KB_OK;
}
s_maps[l][m] = "";
int k = 0;
for (const char* const* p = s_maps[l]; **p; p++) {
if (strcmp(*p, "\n") == 0) continue;
uint32_t c;
if (strcmp(*p, " ") == 0) c = 6;
else if (strcmp(*p, KB_BS) == 0) c = 3 | LV_BUTTONMATRIX_CTRL_CHECKED; // keeps auto-repeat
else if (isSpecial(*p)) c = 3 | LV_BUTTONMATRIX_CTRL_CHECKED | LV_BUTTONMATRIX_CTRL_NO_REPEAT;
else c = 2 | LV_BUTTONMATRIX_CTRL_POPOVER | LV_BUTTONMATRIX_CTRL_NO_REPEAT;
if (k < MAX_KEYS) s_ctrl[l][k++] = (lv_buttonmatrix_ctrl_t)c;
}
}
}
// Label of button `id` (buttons numbered without the "\n" row breaks).
static const char* keyText(uint8_t layout, uint32_t id) {
uint32_t k = 0;
for (const char* const* p = s_maps[layout]; **p; p++) {
if (strcmp(*p, "\n") == 0) continue;
if (k++ == id) return *p;
}
return "";
}
static void apply(lv_obj_t* kbd, uint8_t layout) {
s_layout = layout;
lv_keyboard_set_map(kbd, LV_KEYBOARD_MODE_USER_1, s_maps[layout], s_ctrl[layout]);
lv_keyboard_set_mode(kbd, LV_KEYBOARD_MODE_USER_1);
}
static void releaseShift(lv_obj_t* kbd) {
if (s_layout <= L_PAIRED_END && (s_layout & 1)) apply(kbd, s_layout ^ 1);
}
// ── Variants popup ───────────────────────────────────────────────────────────
static char s_var_txt[12][8];
static const char* s_var_map[13];
static void closePopup() {
if (s_popup) { lv_obj_delete_async(s_popup); s_popup = nullptr; }
}
static void onPopupPick(lv_event_t* e) {
lv_obj_t* m = (lv_obj_t*)lv_event_get_target(e);
uint32_t id = lv_buttonmatrix_get_selected_button(m);
if (id == LV_BUTTONMATRIX_BUTTON_NONE || !s_popup_kbd) return;
lv_obj_t* ta = lv_keyboard_get_textarea(s_popup_kbd);
if (ta) lv_textarea_add_text(ta, lv_buttonmatrix_get_button_text(m, id));
releaseShift(s_popup_kbd);
closePopup();
}
static void onPopupOutside(lv_event_t* e) { (void)e; closePopup(); }
static void openPopup(lv_obj_t* kbd, const char* variants, bool upper) {
closePopup();
int n = ::kbd::utf8Len(variants);
if (n > 12) n = 12;
for (int i = 0; i < n; i++) {
char one[8];
::kbd::utf8At(variants, i, one);
if (upper) ::kbd::toUpperUtf8(one, s_var_txt[i], sizeof(s_var_txt[i]));
else memcpy(s_var_txt[i], one, sizeof(one));
s_var_map[i] = s_var_txt[i];
}
s_var_map[n] = "";
// Full-screen transparent overlay: a tap outside the row closes the popup.
s_popup = lv_obj_create(lv_layer_top());
lv_obj_remove_style_all(s_popup);
lv_obj_set_size(s_popup, LV_PCT(100), LV_PCT(100));
lv_obj_add_flag(s_popup, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(s_popup, onPopupOutside, LV_EVENT_CLICKED, NULL);
s_popup_kbd = kbd;
lv_obj_t* m = lv_buttonmatrix_create(s_popup);
lv_buttonmatrix_set_map(m, s_var_map);
const int key_w = 30;
int w = n * key_w + 8;
lv_obj_set_size(m, w, 44);
lv_obj_set_style_pad_all(m, 3, 0);
lv_obj_set_style_pad_gap(m, 2, 0);
lv_obj_set_style_bg_color(m, lv_color_hex(theme::SURFACE_2), 0);
lv_obj_set_style_border_color(m, lv_color_hex(theme::ACCENT), 0);
lv_obj_set_style_border_width(m, 1, 0);
lv_obj_set_style_radius(m, theme::RADIUS, 0);
lv_obj_set_style_text_font(m, THEME_FONT_LARGE, LV_PART_ITEMS);
// Above the pressed key, but never above the keyboard's own top edge: the
// compose field sits right there and must stay readable while picking.
lv_point_t pt = { 0, 0 };
lv_indev_t* indev = lv_indev_active();
if (indev) lv_indev_get_point(indev, &pt);
lv_area_t kb_area;
lv_obj_get_coords(kbd, &kb_area);
int scr_w = lv_display_get_horizontal_resolution(NULL);
int x = pt.x - w / 2;
if (x < 2) x = 2;
if (x + w > scr_w - 2) x = scr_w - 2 - w;
int y = pt.y - 60;
if (y < kb_area.y1) y = kb_area.y1;
lv_obj_set_pos(m, x, y);
lv_obj_add_event_cb(m, onPopupPick, LV_EVENT_VALUE_CHANGED, NULL);
}
// ── Events ───────────────────────────────────────────────────────────────────
static void onLongPress(lv_event_t* e) {
lv_obj_t* kbd = (lv_obj_t*)lv_event_get_target(e);
uint32_t id = lv_keyboard_get_selected_button(kbd);
if (id == LV_BUTTONMATRIX_BUTTON_NONE) return;
bool upper = s_layout <= L_PAIRED_END && (s_layout & 1);
uint8_t lower = upper ? (uint8_t)(s_layout ^ 1) : s_layout;
const char* variants = ::kbd::variantsFor(keyText(lower, id));
if (!variants) return; // plain key: the release types it as usual
s_suppress_release = true;
openPopup(kbd, variants, upper);
}
static void onEvent(lv_event_t* e) {
lv_obj_t* kbd = (lv_obj_t*)lv_event_get_target(e);
if (s_suppress_release) { s_suppress_release = false; return; }
uint32_t id = lv_keyboard_get_selected_button(kbd);
if (id == LV_BUTTONMATRIX_BUTTON_NONE) return;
const char* t = lv_keyboard_get_button_text(kbd, id);
if (!t) return;
if (strcmp(t, KB_SH) == 0) { if (s_layout <= L_PAIRED_END) apply(kbd, s_layout ^ 1); return; }
if (strcmp(t, K_SYM) == 0) { apply(kbd, L_SYM); return; }
if (strcmp(t, K_BACK) == 0) { apply(kbd, s_script * 2); return; }
if (strcmp(t, K_GLOBE) == 0) {
s_script = (s_script == mainScript()) ? altScript() : mainScript();
apply(kbd, s_script * 2);
return;
}
lv_keyboard_def_event_cb(e); // type the character / backspace / OK
if (strcmp(t, KB_BS) != 0) releaseShift(kbd);
}
static void onDelete(lv_event_t* e) { (void)e; closePopup(); s_popup_kbd = nullptr; }
// Create a keyboard on `parent`, opening on the main script.
static lv_obj_t* create(lv_obj_t* parent, NodePrefs* prefs) {
s_prefs = prefs;
buildMaps(); // cheap; picks up a changed alphabet setting
s_script = mainScript();
lv_obj_t* kbd = lv_keyboard_create(parent);
lv_obj_remove_event_cb(kbd, lv_keyboard_def_event_cb);
lv_obj_add_event_cb(kbd, onEvent, LV_EVENT_VALUE_CHANGED, NULL);
lv_obj_add_event_cb(kbd, onLongPress, LV_EVENT_LONG_PRESSED, NULL);
lv_obj_add_event_cb(kbd, onDelete, LV_EVENT_DELETE, NULL);
lv_keyboard_set_popovers(kbd, true);
apply(kbd, s_script * 2);
return kbd;
}
} // namespace kb