touch: @-mention picker (#42), edit-mode cursor cue, robust edit-state

- @-mention picker (issue #42): typing "@" in a chat composer pops a touch list
  of matching contacts (prefix-filtered, up to 6); tap one to insert "@Name ".
  Passive NAV_SKIP_FLAG overlay like the accent box; touch-select for now. Takes
  priority over the accent box while a mention is active (composerSuggestRefresh).
- Cursor now reflects edit mode: shown only while editing, hidden while a focused
  field is just the keyboard-nav target — a reliable "ready to type" cue
  (navSyncCursor() each nav tick).
- Edit mode is decided only on a genuine move to a different field, so a rebuild
  that re-focuses the same field (e.g. while the accent box is up) no longer
  kicks you out of typing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kaj SchittecatandClaude Opus 4.8 committed 2026-06-24 08:20:40 +02:00
1 parent 02cff3389d
commit 0a3f9fe82f
1 file changed
+156 -10
+156 -10
View File
@@ -1815,14 +1815,19 @@ static bool s_nav_ta_editing = false;
static void navFocusCb(lv_group_t* g) {
lv_obj_t* f = lv_group_get_focused(g);
// Edit mode on focus: the chat composer auto-edits (you opened the chat to type), and a
// field focused by a TOUCH tap edits immediately too — a touch user pointed right at it and
// shouldn't have to press Enter. Every OTHER field (reached with the letter-nav keys) starts
// in navigate mode so those keys keep working — press select/Enter to edit it. Esc/Enter on
// an empty composer drops it back to navigate mode (handled in navPump / handleHwKey).
lv_indev_t* act = lv_indev_get_act(); // the indev driving this focus change (null if programmatic)
const bool by_touch = act && lv_indev_get_type(act) == LV_INDEV_TYPE_POINTER;
s_nav_ta_editing = (f != nullptr && (by_touch || f == g_lv.ch.composer_ta || f == g_lv.dm.composer_ta));
// Edit mode — decided only on a GENUINE move to a different field. The chat composer
// auto-edits (you opened the chat to type), and a field focused by a TOUCH tap edits
// immediately too (you pointed right at it). Every OTHER field (reached with the letter-nav
// keys) starts in navigate mode so those keys keep working — press select/Enter to edit it.
// Crucially, a navMaybeRebuild() that re-focuses the SAME field (e.g. while the accent box is
// up) must NOT reset edit mode — otherwise it kicks you out of typing mid-word.
static lv_obj_t* s_nav_focus_prev = nullptr;
if (f != s_nav_focus_prev) {
lv_indev_t* act = lv_indev_get_act(); // the indev driving this focus change (null if programmatic)
const bool by_touch = act && lv_indev_get_type(act) == LV_INDEV_TYPE_POINTER;
s_nav_ta_editing = (f != nullptr && (by_touch || f == g_lv.ch.composer_ta || f == g_lv.dm.composer_ta));
s_nav_focus_prev = f;
}
if (s_nav_styled && s_nav_styled != f) navUnstyle(s_nav_styled);
s_nav_styled = nullptr; // old highlight (if any) is now restored; nothing styled yet
if (!f || !s_nav_show) return; // focus-visible: paint only while actively keyboard-navigating
@@ -2053,6 +2058,23 @@ static lv_obj_t* navFocusedTextarea() {
return (f && lv_obj_check_type(f, &lv_textarea_class)) ? f : nullptr;
}
// The text cursor is the reliable "you can type now" cue: show it only while editing, hide it
// while a focused field is merely the keyboard-nav target. Synced each nav tick — the per-field
// LV_PART_CURSOR opacity override is a no-op when unchanged, so it's cheap; the previous field's
// cursor is restored when focus moves off it (so it reads normally outside keyboard-nav).
static lv_obj_t* s_nav_cursor_ta = nullptr;
static bool s_nav_cursor_shown = true;
static void navSyncCursor() {
lv_obj_t* tf = navFocusedTextarea();
const bool want = (tf != nullptr) && s_nav_ta_editing;
if (tf == s_nav_cursor_ta && want == s_nav_cursor_shown) return; // unchanged → nothing to do
if (s_nav_cursor_ta && s_nav_cursor_ta != tf && lv_obj_is_valid(s_nav_cursor_ta))
lv_obj_set_style_opa(s_nav_cursor_ta, LV_OPA_COVER, LV_PART_CURSOR); // restore on leave
if (tf) lv_obj_set_style_opa(tf, want ? LV_OPA_COVER : LV_OPA_TRANSP, LV_PART_CURSOR);
s_nav_cursor_ta = tf;
s_nav_cursor_shown = want;
}
// Drain badge-bsp key events (called from UITask::loop). Focus-movement / activation
// is queued for the LVGL keypad indev; TEXT is applied DIRECTLY to the focused field
// so the physical keyboard types straight in (T-Deck-style) — no on-screen keyboard,
@@ -2295,6 +2317,7 @@ static LvChatPanel* s_nav_prev_chat = nullptr; // last chat panel we focused (
static void navMaybeRebuild() {
if (!s_nav_group) return;
navSyncCursor(); // keep the text cursor showing only in edit mode (reliable "ready to type" cue)
if (navOpenDropdown()) return; // an open dropdown owns the group — rebuilding would close it
lv_obj_t* scr = lv_scr_act();
lv_obj_t* top = lv_layer_top();
@@ -2541,6 +2564,7 @@ static void refreshLogModalView();
static void hideKb();
static void accentExit(); // long-press accent picker (issue #22, dead on touch)
static void accentBoxHide(); // tap-to-pick accent box (issue #22)
static void mentionBoxHide(); // tap-to-pick @-mention contact picker (issue #42)
static void txtMenuHide(); // cut/copy/paste/select-all edit menu
static void showKb(LvChatPanel* p);
static void closeSettingsModal();
@@ -3250,6 +3274,7 @@ static void kbMirrorBind(lv_obj_t* real_ta) {
static void hideKb() {
accentExit(); // tear down any open accent picker
accentBoxHide();
mentionBoxHide(); // tear down any open @-mention picker
txtMenuHide(); // tear down any open edit menu
kbMirrorSyncToReal();
s_kb_bind_ta = nullptr;
@@ -3638,6 +3663,127 @@ static void accentBoxMaybeShow() {
lv_obj_move_foreground(s_accbox);
}
// ---- @-mention contact picker (issue #42) --------------------------------
// Typing "@name" in a chat composer pops a touch list of matching contacts —
// modeled on the accent box: a passive overlay (NAV_SKIP_FLAG) that never steals
// keyboard-nav focus and, for now, is selected by TOUCH only. Tapping a row
// replaces the "@partial" with "@FullName ".
static lv_obj_t* s_mentionbox = nullptr;
static lv_obj_t* s_mentionbox_ta = nullptr;
static constexpr int k_mention_max = 6;
static char s_mention_names[k_mention_max][32]; // kept alive for the cell callbacks
static void mentionBoxHide() {
if (s_mentionbox) { lv_obj_del(s_mentionbox); s_mentionbox = nullptr; }
s_mentionbox_ta = nullptr;
}
// Index of the active mention's '@' in `text`, or -1. The active mention is the
// last '@' that begins a word (start of text or after whitespace) with no
// whitespace between it and the end (where the caret is).
static int mentionAtPos(const char* text) {
if (!text) return -1;
for (int i = (int)strlen(text) - 1; i >= 0; --i) {
const char c = text[i];
if (c == ' ' || c == '\n' || c == '\t') return -1; // a space before any '@' -> not in a mention
if (c == '@') {
const bool at_word_start = (i == 0) || text[i-1] == ' ' || text[i-1] == '\n' || text[i-1] == '\t';
return at_word_start ? i : -1; // '@' mid-word (e.g. an email) isn't a mention
}
}
return -1;
}
static void mentionBoxCellCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
const char* name = static_cast<const char*>(lv_event_get_user_data(e));
lv_obj_t* ta = s_mentionbox_ta;
if (ta && name && name[0]) {
const char* text = lv_textarea_get_text(ta);
const int at = mentionAtPos(text);
if (at >= 0) {
char out[300];
snprintf(out, sizeof out, "%.*s@%s ", at, text, name); // keep text before '@', then "@Name "
lv_textarea_set_text(ta, out);
if (ta == s_kb_mirror_ta) kbMirrorSyncToReal();
}
}
mentionBoxHide();
}
// Show the contact picker when the composer ends in "@partial". Returns true
// when a picker is shown (so the caller suppresses the accent box).
static bool mentionBoxMaybeShow() {
mentionBoxHide();
if (!g_lv.keyboard) return false;
lv_obj_t* ta = lv_keyboard_get_textarea(g_lv.keyboard);
if (!ta) return false;
const char* text = lv_textarea_get_text(ta);
const int at = mentionAtPos(text);
if (at < 0) return false;
const char* partial = text + at + 1;
const size_t plen = strlen(partial);
int n = 0;
const int total = the_mesh.getNumContacts();
for (int i = 0; i < total && n < k_mention_max; ++i) {
ContactInfo c;
if (!the_mesh.getContactByIdx(i, c) || !c.name[0]) continue;
if (plen > 0 && strncasecmp(c.name, partial, plen) != 0) continue; // prefix match (case-insensitive)
strlcpy(s_mention_names[n], c.name, sizeof s_mention_names[n]);
++n;
}
if (n == 0) return false;
s_mentionbox_ta = ta;
s_mentionbox = lv_obj_create(lv_layer_top());
lv_obj_add_flag(s_mentionbox, NAV_SKIP_FLAG); // passive, touch-only — never a keyboard-nav stop (issue #42)
lv_obj_remove_style_all(s_mentionbox);
lv_obj_set_style_bg_color(s_mentionbox, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
lv_obj_set_style_bg_opa(s_mentionbox, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_radius(s_mentionbox, 8, LV_PART_MAIN);
lv_obj_set_style_border_color(s_mentionbox, lv_color_hex(0x2A3D52), LV_PART_MAIN);
lv_obj_set_style_border_width(s_mentionbox, 1, LV_PART_MAIN);
lv_obj_set_style_pad_all(s_mentionbox, 4, LV_PART_MAIN);
lv_obj_set_style_pad_row(s_mentionbox, 3, LV_PART_MAIN);
lv_obj_set_flex_flow(s_mentionbox, LV_FLEX_FLOW_COLUMN);
lv_obj_clear_flag(s_mentionbox, LV_OBJ_FLAG_SCROLLABLE);
const int rowh = 30, boxw = 168;
for (int i = 0; i < n; ++i) {
lv_obj_t* b = lv_btn_create(s_mentionbox);
lv_obj_add_flag(b, NAV_SKIP_FLAG);
lv_obj_set_size(b, boxw, rowh);
lv_obj_set_style_radius(b, 5, LV_PART_MAIN);
lv_obj_set_style_bg_color(b, lv_color_hex(0x1B2B3A), LV_PART_MAIN);
lv_obj_set_style_bg_color(b, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN | LV_STATE_PRESSED);
lv_obj_add_event_cb(b, mentionBoxCellCb, LV_EVENT_CLICKED, (void*)s_mention_names[i]);
lv_obj_t* l = lv_label_create(b);
lv_label_set_text_fmt(l, "@%s", s_mention_names[i]);
lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN);
lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN);
lv_label_set_long_mode(l, LV_LABEL_LONG_DOT);
lv_obj_set_width(l, boxw - 18);
lv_obj_center(l);
}
lv_obj_set_size(s_mentionbox, boxw + 8, n * rowh + (n - 1) * 3 + 8);
// Place it above the composer, clamped above the keyboard (mirrors the accent box).
lv_obj_update_layout(s_mentionbox);
lv_area_t a; lv_obj_get_coords(ta, &a);
const lv_coord_t bw = lv_obj_get_width(s_mentionbox), bh = lv_obj_get_height(s_mentionbox);
lv_coord_t bx = (lv_disp_get_hor_res(nullptr) - bw) / 2;
lv_coord_t by = a.y1 - bh - 4;
if (g_lv.keyboard && !lv_obj_has_flag(g_lv.keyboard, LV_OBJ_FLAG_HIDDEN)) {
lv_coord_t limit = lv_disp_get_ver_res(nullptr) - chatKbH() - 2;
if (s_kb_panel) limit -= s_comp_h;
if (by + bh > limit) by = limit - bh;
}
if (by < STATUSBAR_H + 2) by = STATUSBAR_H + 2;
lv_obj_set_pos(s_mentionbox, bx, by);
lv_obj_move_foreground(s_mentionbox);
return true;
}
// One entry point for the composer's typing-time suggestion overlays: the
// @-mention picker takes priority over the accent box (never both at once).
static void composerSuggestRefresh() {
if (mentionBoxMaybeShow()) { accentBoxHide(); return; }
mentionBoxHide();
accentBoxMaybeShow();
}
static void keyboardCb(lv_event_t* e) {
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_READY || code == LV_EVENT_CANCEL) { accentExit(); accentBoxHide(); hideKb(); }
@@ -3647,7 +3793,7 @@ static void keyboardCb(lv_event_t* e) {
else if (code == LV_EVENT_VALUE_CHANGED) {
kbSetRotateArrowsOpa(LV_OPA_20);
accentHandleValueChanged();
accentBoxMaybeShow(); // letter with accents -> show the tap-to-pick box
composerSuggestRefresh(); // @mention contact picker, else the accent box
}
}
@@ -22932,7 +23078,7 @@ static void handleHwKey(int key) {
} else {
lv_textarea_add_char(ta, (uint32_t)key);
}
accentBoxMaybeShow(); // letter with accents -> show the tap-to-pick box
composerSuggestRefresh(); // @mention contact picker, else the accent box
}
if (g_lv.task) g_lv.task->noteUserInput();
}