From 0a3f9fe82ff3bb4f24e43e593f2fbdbe21890843 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Wed, 24 Jun 2026 08:20:40 +0200 Subject: [PATCH] touch: @-mention picker (#42), edit-mode cursor cue, robust edit-state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @-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) --- src/ui-touch/UITask.cpp | 166 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 156 insertions(+), 10 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 48f7b30..82248be 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -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(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(); }