Compare commits

...
11 Commits
Author SHA1 Message Date
Kaj Schittecat 475245c90e chore: refresh Mesh America catalog for beta_15 2026-06-24 11:01:48 +02:00
Kaj SchittecatandClaude Opus 4.8 1f43a8ba37 release: beta_15 notes — amber power-save battery, @-mention picker (#42), overlay scroll-keep (#45), app-send sync (#46), kbd-nav off by default
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:58:45 +02:00
Kaj SchittecatandClaude Opus 4.8 d491065719 touch: power-save shows as an amber battery, not a moon icon (T-Deck)
Idle power-save (light-sleep) advertised itself with a separate crescent-moon
glyph in the status bar. Replace it with an iPhone-Low-Power-Mode-style amber
tint on the battery icon (iOS systemYellow): clearer, and frees the bar slot.
The moon stays in the tree but hidden, keeping the layout and its tap handler.

A single applyBattColor() helper owns the battery colour so the map-chrome
restyle (black/white over light tiles) and the per-tick refresh never fight —
the amber overlays whatever base colour the context wants when power-save is on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:54:06 +02:00
Kaj SchittecatandClaude Opus 4.8 d11a5bb6b2 touch: keep list scroll position when closing an overlay (issue #45)
Closing a settings item / modal / chat with keyboard-nav on snapped the
underlying list back to the top. navMaybeRebuild rebuilds the focus group when
the overlay closes, but the previously-focused element lived inside the overlay
and is gone, so focus fell to the first list item and the list scrolled up to
show it. Affected every screen because every overlay closes through this path.

Stash the page item that had focus when leaving for an overlay/chat, and restore
it on return — so focus, and the scroll position, land back where they were.
Falls back to the prior behaviour if that item is genuinely gone. Most visible on
beta_14 (keyboard-nav defaulted on); new installs now default it off.

Reported by ZorkaTheDog (#45).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:31:47 +02:00
Kaj SchittecatandClaude Opus 4.8 c8849916df touch: show companion-app-sent DMs on the device (issue #46, half 1)
A DM sent from the MeshCore companion app went out over the mesh but never
appeared on the T-Deck: the wire protocol deliberately doesn't echo sent
messages back to clients, so the on-device UI (a separate consumer) never saw
app-originated sends. Add an appSentMsgToContact() UI hook (default no-op for
non-touch UIs); the touch UI mirrors the message as a local outgoing bubble in
the recipient's thread. Called from the CMD_SEND_TXT_MSG handler on first
handling only (a client/transport retry was already mirrored).

Still open: the receive direction (T-Deck-received messages reaching the app) —
queueMessage already pushes RESP_CODE_CONTACT_MSG_RECV_V3 to all transports, so
that half needs a frame capture against the real app to pin down.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:54:38 +02:00
Kaj SchittecatandClaude Opus 4.8 278b31cc92 touch: keyboard navigation OFF by default (Tanmatsu stays on)
Reverses the v15 force-on. New installs and existing T-Deck/V4 devices now
default to keyboard-nav OFF (a v18 one-time migration flips existing installs
to off); the user's later explicit on/off then persists. The Tanmatsu is exempt
— it has no touchscreen, so keyboard nav is its only input and stays on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:39:28 +02:00
Kaj SchittecatandClaude Opus 4.8 24f338438e touch: tapping an already-selected field enters edit mode
The touch-to-edit path keyed off navFocusCb (a focus CHANGE), so a field that was
already the selected one — e.g. the Profile node-name field, auto-selected on
entry — couldn't be made typable by tapping it (the tap doesn't re-focus, so
navFocusCb never fires). settingsFieldFocusCb already runs on CLICKED; now a real
tap also sets edit mode, so a selected field becomes typable on tap. Keyboard-nav
onto a field still starts in navigate mode (press Enter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:29:05 +02:00
Kaj SchittecatandClaude Opus 4.8 3e1b45504f touch: navTreeSig must not COUNT NAV_SKIP children (complete the accent-box fix)
The previous fix returned 0 from the recursion for NAV_SKIP/HIDDEN subtrees, but
the parent loop still iterated over the added/removed child (h = h*prime + 0),
so the child COUNT still moved the signature. The accent box + @-mention picker
are created/deleted on every keystroke, so the count flipped each time → rebuild
→ edit-mode reset → typing broke mid-word (and Esc then read as navigate-back,
exiting the screen). Now skip those children in the loop entirely so they never
affect the signature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:22:18 +02:00
Kaj SchittecatandClaude Opus 4.8 12c34a2b35 touch: accent/mention box no longer breaks typing in non-chat fields
Non-chat text fields (add-channel, Wi-Fi, room login, settings modals) live on
the top UI layer, so keyboard nav is rooted there (useTop). The accent box and
@-mention picker are ALSO on the top layer, and they were counting toward
navTreeSig() — so each keystroke that showed/hid the box flipped the signature,
triggered a navMaybeRebuild(), and that rebuild's remove-all + re-focus churn
reset edit mode, dropping you out of typing mid-word.

- navTreeSig() now skips NAV_SKIP_FLAG subtrees (accent box, mention picker), so
  those passive overlays never trigger a rebuild.
- navFocusCb() ignores the transient f==null a rebuild's remove-all emits, so any
  other rebuild can't reset edit mode either.

(The T-Deck never used the keyboard mirror — kbMirrorActive() is false there; the
mirror is V4-only for the on-screen keyboard.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:05:25 +02:00
Kaj SchittecatandClaude Opus 4.8 0a3f9fe82f 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>
2026-06-24 08:20:40 +02:00
Kaj SchittecatandClaude Opus 4.8 02cff3389d touch: tap-to-edit on touch focus + map-render watchdog yield
- Keyboard nav: tapping a text field by touch now enters edit mode immediately
  (focus came from the pointer indev) instead of requiring Enter. Reaching a
  field with the letter-nav keys still starts in navigate mode (press Enter).
- Map: yield to IDLE after each tile in renderMapTiles() so a slow multi-tile
  render on loopTask can't starve the task watchdog and panic — fixes a beta_14
  TWDT reboot seen during map use (CPU1=loopTask). The download worker already
  does the same vTaskDelay(1) on its side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:08:16 +02:00
7 changed files with 302 additions and 39 deletions

No files matched your search

+8 -8
View File
@@ -16,19 +16,19 @@
"role": "gui",
"title": "Touch UI",
"version": {
"beta_14": {
"notes": "Fixed a bug introduced in beta_13: if you had turned keyboard navigation OFF (Settings -> Keyboard), the physical keyboard stopped typing into text fields entirely on the T-Deck and Tanmatsu. Typing now works again with keyboard navigation off; with it on, nothing changed. (The Heltec V4 was never affected.)\nIncludes everything from beta_13: room-server posts show the author's name, the Direct (0-hop) contact + map filter, beer emoji with a status-bar unread tap and a new-contact toast toggle and Czech accents, optional trackball edge-scroll, the dedicated special-characters picker, the 12-hour clock applied everywhere, and the keyboard navigation + typing improvements.",
"beta_15": {
"notes": "Power-saving now shows as an amber battery icon in the status bar — like iPhone's Low Power Mode — instead of a separate moon icon (T-Deck).\nNew @-mention picker: type @ in the message box to pick a contact from a list and drop their name in (thanks robinshizuyo-dev, #42).\nClosing a settings item, menu or chat no longer snaps the list back to the top — it stays where you left it (thanks ZorkaTheDog, #45).\nMessages you send from the MeshCore companion app now also show up on the device, in the right conversation (thanks HonzaDandy, #46).\nTyping is more reliable on the T-Deck and Tanmatsu: tapping a text field — even one that's already highlighted — starts typing right away, and the accent pop-up no longer interrupts typing outside of chats.\nThe text cursor now only appears when a field is actually ready for input, so it's a dependable \"you can type now\" cue.\nSmoother map panning: tile rendering no longer trips the watchdog and reboots during heavy scrolling.\nKeyboard navigation is now OFF by default. If you prefer driving the UI with the trackball / arrow keys, switch it back on in Settings -> Keyboard (your choice sticks until you change it). The Tanmatsu keeps it on, since it has no touchscreen.\nIncludes everything from beta_14 and the earlier betas.",
"files": [
{
"type": "flash-wipe",
"name": "wadamesh-heltec-v4-tft-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_14/wadamesh-heltec-v4-tft-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_15/wadamesh-heltec-v4-tft-merged.bin",
"title": "Full install (bootloader + firmware)"
},
{
"type": "flash-update",
"name": "wadamesh-heltec-v4-tft.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_14/wadamesh-heltec-v4-tft.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_15/wadamesh-heltec-v4-tft.bin",
"title": "Update (app only)"
}
]
@@ -48,19 +48,19 @@
"role": "gui",
"title": "Touch UI",
"version": {
"beta_14": {
"notes": "Fixed a bug introduced in beta_13: if you had turned keyboard navigation OFF (Settings -> Keyboard), the physical keyboard stopped typing into text fields entirely on the T-Deck and Tanmatsu. Typing now works again with keyboard navigation off; with it on, nothing changed. (The Heltec V4 was never affected.)\nIncludes everything from beta_13: room-server posts show the author's name, the Direct (0-hop) contact + map filter, beer emoji with a status-bar unread tap and a new-contact toast toggle and Czech accents, optional trackball edge-scroll, the dedicated special-characters picker, the 12-hour clock applied everywhere, and the keyboard navigation + typing improvements.",
"beta_15": {
"notes": "Power-saving now shows as an amber battery icon in the status bar — like iPhone's Low Power Mode — instead of a separate moon icon (T-Deck).\nNew @-mention picker: type @ in the message box to pick a contact from a list and drop their name in (thanks robinshizuyo-dev, #42).\nClosing a settings item, menu or chat no longer snaps the list back to the top — it stays where you left it (thanks ZorkaTheDog, #45).\nMessages you send from the MeshCore companion app now also show up on the device, in the right conversation (thanks HonzaDandy, #46).\nTyping is more reliable on the T-Deck and Tanmatsu: tapping a text field — even one that's already highlighted — starts typing right away, and the accent pop-up no longer interrupts typing outside of chats.\nThe text cursor now only appears when a field is actually ready for input, so it's a dependable \"you can type now\" cue.\nSmoother map panning: tile rendering no longer trips the watchdog and reboots during heavy scrolling.\nKeyboard navigation is now OFF by default. If you prefer driving the UI with the trackball / arrow keys, switch it back on in Settings -> Keyboard (your choice sticks until you change it). The Tanmatsu keeps it on, since it has no touchscreen.\nIncludes everything from beta_14 and the earlier betas.",
"files": [
{
"type": "flash-wipe",
"name": "wadamesh-tdeck-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_14/wadamesh-tdeck-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_15/wadamesh-tdeck-merged.bin",
"title": "Full install (bootloader + firmware)"
},
{
"type": "flash-update",
"name": "wadamesh-tdeck.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_14/wadamesh-tdeck.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_15/wadamesh-tdeck.bin",
"title": "Update (app only)"
}
]
+18
View File
@@ -0,0 +1,18 @@
# beta_15 (one user-facing note per non-blank, non-# line; # lines are section comments)
# --- New ---
Power-saving now shows as an amber battery icon in the status bar — like iPhone's Low Power Mode — instead of a separate moon icon (T-Deck).
New @-mention picker: type @ in the message box to pick a contact from a list and drop their name in (thanks robinshizuyo-dev, #42).
# --- Fixed ---
Closing a settings item, menu or chat no longer snaps the list back to the top — it stays where you left it (thanks ZorkaTheDog, #45).
Messages you send from the MeshCore companion app now also show up on the device, in the right conversation (thanks HonzaDandy, #46).
Typing is more reliable on the T-Deck and Tanmatsu: tapping a text field — even one that's already highlighted — starts typing right away, and the accent pop-up no longer interrupts typing outside of chats.
The text cursor now only appears when a field is actually ready for input, so it's a dependable "you can type now" cue.
Smoother map panning: tile rendering no longer trips the watchdog and reboots during heavy scrolling.
# --- Changed ---
Keyboard navigation is now OFF by default. If you prefer driving the UI with the trackball / arrow keys, switch it back on in Settings -> Keyboard (your choice sticks until you change it). The Tanmatsu keeps it on, since it has no touchscreen.
# --- Also in this release ---
Includes everything from beta_14 and the earlier betas.
+8
View File
@@ -86,6 +86,14 @@ public:
(void)author_name;
newMsgFromPubWithMeta(path_len, is_flood, from_pub, from_name, text, msgcount, snr_q4, rssi);
}
/** A message the COMPANION APP sent on our behalf (CMD_SEND_TXT_MSG). The wire protocol
* deliberately doesn't echo sent messages back to clients, so the on-device UI is otherwise
* the only consumer that never sees app-originated sends — this lets it mirror one as a local
* outgoing bubble so it shows on-device too (issue #46). Default no-op for non-touch UIs. */
virtual void appSentMsgToContact(const uint8_t* to_pub, const char* to_name, const char* text,
uint32_t ack_hash) {
(void)to_pub; (void)to_name; (void)text; (void)ack_hash;
}
virtual void notify(UIEventType t = UIEventType::none) = 0;
virtual void appendDiag(const char* message) { (void)message; }
/** Notify UI of an advert reception. `is_new=true` means the contact is NOT in
+4
View File
@@ -2850,6 +2850,10 @@ void MyMesh::handleCmdFrame(size_t len) {
memcpy(&out_frame[2], &expected_ack, 4);
memcpy(&out_frame[6], &est_timeout, 4);
_serial->writeFrame(out_frame, 10);
// Mirror the app-sent DM into the on-device touch UI so it shows there too (#46). First
// handling only — a transport/client retry (skip_radio_send) was already mirrored.
if (_ui && !skip_radio_send && recipient)
_ui->appSentMsgToContact(recipient->id.pub_key, recipient->name, text, expected_ack);
// Do not synthesize a private-message "recv" frame from self. That frame has no recipient
// context and can be rendered by clients as a chat with self.
+14 -6
View File
@@ -35,7 +35,7 @@ static bool s_begun = false;
// short read (→ treat as absent → defaults); `ver` lets later builds add fields.
static const char* KEY_CFG = "cfg";
static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg)
static const uint8_t TOUCH_CFG_VER = 17; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact
static const uint8_t TOUCH_CFG_VER = 18; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on)
// Defaults (kept identical to the historical per-key defaults).
static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20;
@@ -146,7 +146,11 @@ static void cfgSetDefaults(TouchCfg& c) {
c.map_show_contacts = 1;
c.app_grid_large = 0; // default: compact app grid (T-Deck 4 cols / V4 3 cols)
c.ui_scale = 0; // default: 100% UI scale (native resolution)
c.kbd_nav = 1; // default: keyboard navigation ON (v15)
#if defined(HAS_TANMATSU)
c.kbd_nav = 1; // Tanmatsu: no touchscreen — keyboard nav is the only input, always on
#else
c.kbd_nav = 0; // T-Deck / V4: keyboard navigation OFF by default (opt-in; persists once toggled on)
#endif
c.sleep_idle = 0; // default: idle light-sleep OFF
{ const char* d = "ertui"; for (int i = 0; i < 5; i++) c.nav_keys[i] = (uint8_t)d[i]; } // default tab hotkeys E/R/T/U/I
c.map_zoom_buttons = 0; // default: map zoom = slider
@@ -202,10 +206,14 @@ static void cfgLoadOrMigrate() {
// to the real Custom index on the first touchPrefsGetTimezone() call (the
// zone count isn't known here). offset 0 stays zone 0 (Europe) = unchanged.
if (s_cfg.ver < 3 && s_cfg.time_offs != 0) s_cfg.tz_zone = 0xFE;
// v15: keyboard navigation is now ON by default. Force it on ONCE for existing
// users (pre-v15) so they get the feature; their later explicit on/off then
// persists (this only fires for ver < 15, never again).
if (s_cfg.ver < 15) s_cfg.kbd_nav = 1;
// v18: keyboard navigation is now OFF by default (it was force-enabled at v15, but it
// complicated the touch UX more than it helped). Reset existing installs to off ONCE so
// they match the new default; the user's later explicit on/off then persists (fires only
// for ver < 18, never again). The Tanmatsu is exempt — it has no touchscreen, so keyboard
// nav is its only input and must stay on.
#if !defined(HAS_TANMATSU)
if (s_cfg.ver < 18) s_cfg.kbd_nav = 0;
#endif
s_cfg.ver = TOUCH_CFG_VER;
s_cfg.magic = TOUCH_CFG_MAGIC;
cfgFlush(); // rewrite with new fields defaulted-in
+248 -25
View File
@@ -1815,11 +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 the
// cursor shows immediately); every OTHER field starts in navigate mode so the letter-nav
// 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).
s_nav_ta_editing = (f != nullptr && (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 && f != s_nav_focus_prev) { // ignore the transient f==null a rebuild's remove-all emits
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
@@ -2050,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,
@@ -2264,11 +2289,22 @@ static void navMarkDirty() { s_nav_dirty = true; }
// wizard steps that swap content without changing the top-level child count (a plain
// child-count signature missed those, leaving the focus group empty after a step swap).
static uint32_t navTreeSig(lv_obj_t* obj, int depth) {
if (!obj || depth > 7 || lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) return 0;
// Skip NAV_SKIP_FLAG overlays (accent box, @-mention picker): they're not nav targets, so
// their appearing/disappearing must NOT change the signature and trigger a rebuild — that
// rebuild's remove-all + re-focus churn would reset edit mode and break typing mid-word.
if (!obj || depth > 7 || lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN) || lv_obj_has_flag(obj, NAV_SKIP_FLAG)) return 0;
uint32_t h = (uint32_t)(uintptr_t)obj * 2654435761u + (uint32_t)depth + 1u;
uint32_t n = lv_obj_get_child_cnt(obj);
for (uint32_t i = 0; i < n; i++)
h = h * 2654435761u + navTreeSig(lv_obj_get_child(obj, i), depth + 1);
for (uint32_t i = 0; i < n; i++) {
lv_obj_t* c = lv_obj_get_child(obj, i);
// Skip the child ENTIRELY (don't even count it) when it's hidden or a passive NAV_SKIP
// overlay — returning 0 from the recursion wasn't enough, because merely iterating over an
// added/removed child still moves `h`. The accent box + @-mention picker are created and
// deleted on every keystroke, so counting them flips the signature → rebuild → edit-mode
// reset → typing breaks mid-word (and Esc then reads as "navigate back", exiting the screen).
if (!c || lv_obj_has_flag(c, LV_OBJ_FLAG_HIDDEN) || lv_obj_has_flag(c, NAV_SKIP_FLAG)) continue;
h = h * 2654435761u + navTreeSig(c, depth + 1);
}
return h;
}
@@ -2292,7 +2328,13 @@ 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
// Issue #45: when an overlay (settings sheet, modal) or a chat closes, the element that had focus
// lived inside it and is now gone, so focus would fall to the first list item and the underlying
// list would snap to the top. Stash the page focus on the way out, restore it on the way back.
static lv_obj_t* s_nav_page_focus = nullptr;
static bool s_nav_prev_on_page = true;
lv_obj_t* scr = lv_scr_act();
lv_obj_t* top = lv_layer_top();
bool useTop = navTopHasVisibleChild(top);
@@ -2301,6 +2343,7 @@ static void navMaybeRebuild() {
// screens) and the composer can be focused for typing.
LvChatPanel* chat = useTop ? nullptr : navOpenChatPanel();
lv_obj_t* root = useTop ? top : (chat ? chat->overlay : scr);
const bool on_page = !useTop && !chat; // #45: true = main tab content (the list that scrolls)
lv_obj_t* tabbar = nullptr;
if (!useTop && !chat && g_lv.tabview) {
// Main app: collect ONLY the active tab's content (inactive tabs are scrolled off-screen, not
@@ -2315,6 +2358,7 @@ static void navMaybeRebuild() {
if (!s_nav_dirty && scr == s_nav_prev_scr && sig == s_nav_prev_sig) return;
s_nav_dirty = false; s_nav_prev_scr = scr; s_nav_prev_sig = sig;
lv_obj_t* keep = lv_group_get_focused(s_nav_group); // preserve focus across a same-page rebuild (toggle/click)
if (!on_page && s_nav_prev_on_page) s_nav_page_focus = keep; // #45: leaving the page → remember where we were
lv_group_remove_all_objs(s_nav_group);
s_nav_first = s_nav_last = nullptr; s_nav_count = 0;
if (root) navCollect(root); // content items (focus starts here)
@@ -2330,6 +2374,11 @@ static void navMaybeRebuild() {
// not auto-selected). Only on the open transition, so new messages don't steal focus mid-typing.
if (chat && chat != s_nav_prev_chat && chat->composer_ta && lv_obj_is_valid(chat->composer_ta)) {
lv_group_focus_obj(chat->composer_ta); // chat just opened → focus the composer
} else if (on_page && !s_nav_prev_on_page && s_nav_page_focus && lv_obj_is_valid(s_nav_page_focus)) {
// #45: just returned to the page from an overlay/chat — restore the item we left from so the
// list stays put instead of snapping to the top.
const int n = s_nav_count < kNavMax ? s_nav_count : kNavMax;
for (int i = 0; i < n; i++) if (s_nav_objs[i] == s_nav_page_focus) { lv_group_focus_obj(s_nav_page_focus); break; }
} else if (keep && lv_obj_is_valid(keep)) {
// A toggle/click triggers a rebuild; if the previously-focused element survived,
// keep focus on it instead of jumping back to the top of the page.
@@ -2337,6 +2386,7 @@ static void navMaybeRebuild() {
for (int i = 0; i < n; i++) if (s_nav_objs[i] == keep) { lv_group_focus_obj(keep); break; }
}
s_nav_prev_chat = chat;
s_nav_prev_on_page = on_page; // #45
if (s_nav_debug) printf("[NAV] rebuilt count=%d %s tab=%d\n", s_nav_count, useTop ? "top" : chat ? "chat" : "tab", getActiveTab());
}
@@ -2538,6 +2588,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();
@@ -3247,6 +3298,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;
@@ -3635,6 +3687,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(); }
@@ -3644,7 +3817,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
}
}
@@ -4651,6 +4824,13 @@ static void settingsFieldFocusCb(lv_event_t* e) {
s_kb_panel = nullptr;
kbMirrorBind(ta);
noteKbActivity(); // focusing/tapping a field lights the auto backlight
#if defined(HAS_TANMATSU) || defined(HAS_TDECK_TRACKBALL)
// A deliberate TAP enters edit mode immediately — even when the field was already the
// selected (nav-focused) one, where no focus change fires navFocusCb (so tapping it again
// wouldn't otherwise make it typable). CLICKED only fires on a real tap (past the scroll
// guards above); keyboard-nav onto a field still starts in navigate mode (press Enter).
if (code == LV_EVENT_CLICKED) s_nav_ta_editing = true;
#endif
}
#if defined(HAS_TDECK_KEYBOARD)
@@ -17009,6 +17189,12 @@ static void renderMapTiles() {
// Wi-Fi worker holds a socket pushed the heap into an OOM reboot. When tiles
// are arriving from the network, let LVGL batch one redraw at the end.
if (s_tile_fetch_pending == 0) lv_refr_now(NULL);
// Yield to the IDLE task after each tile's read + decode + paint. This loop
// runs on loopTask, which is what the task watchdog watches via IDLE1; a full
// 9-tile load (or a few slow SD reads) can otherwise pin the core long enough
// to starve IDLE1 and panic (the loopTask Task-WDT reboot this fixes — the
// download worker already does the same vTaskDelay(1) on its side).
vTaskDelay(1);
}
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
@@ -18771,6 +18957,21 @@ static void onMapTabActivated() {
refreshMapInfoLabel();
}
// Idle power-save indicator (iPhone Low-Power-Mode style, T-Deck only): instead of a separate moon
// glyph, the status-bar battery turns amber while idle power-save is enabled. s_batt_base holds the
// colour the theme/map chrome wants (off-white off-map, black/white over light tiles); applyBattColor
// overlays the amber when power-save is on, so the map-chrome setter and the per-tick refresh share
// one writer and never fight over the battery colour.
static lv_color_t s_batt_base = lv_color_hex(COLOR_TEXT);
static void applyBattColor() {
if (!g_statusbar.batt_icon) return;
lv_color_t c = s_batt_base;
#if defined(HAS_TDECK_GT911)
if (touchSleep::enabled()) c = lv_color_hex(0xFFD60A); // iOS systemYellow ≈ Low Power Mode
#endif
lv_obj_set_style_text_color(g_statusbar.batt_icon, c, LV_PART_MAIN);
}
// Immersive map chrome: on the Map tab the status bar, bottom info strip and
// tab bar all go transparent so the (full-screen) map shows through behind them.
// Because OSM tiles are LIGHT, the status-bar text/icons are switched to BLACK
@@ -18801,7 +19002,8 @@ static void applyMapChrome(bool on) {
const lv_color_t fg = lv_color_hex(!on ? COLOR_TEXT : (night ? 0xF0F0F0 : 0x000000));
const lv_color_t fg_sub = lv_color_hex(!on ? COLOR_SUB : (night ? 0xC8CCD0 : 0x000000));
if (g_statusbar.left_label) lv_obj_set_style_text_color(g_statusbar.left_label, fg, LV_PART_MAIN);
if (g_statusbar.batt_icon) lv_obj_set_style_text_color(g_statusbar.batt_icon, fg, LV_PART_MAIN);
s_batt_base = fg; // theme/map-driven battery colour...
applyBattColor(); // ...with the power-save amber overlaid if enabled
if (g_statusbar.batt_pct) lv_obj_set_style_text_color(g_statusbar.batt_pct, fg_sub, LV_PART_MAIN);
if (g_statusbar.clock) lv_obj_set_style_text_color(g_statusbar.clock, fg_sub, LV_PART_MAIN);
if (g_statusbar.conn_icon) lv_obj_set_style_text_color(g_statusbar.conn_icon, fg_sub, LV_PART_MAIN);
@@ -22923,7 +23125,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();
}
@@ -24967,20 +25169,10 @@ static void updateGlobalStatusBar() {
#if defined(HAS_TDECK_GT911)
// ---- Idle power-save icon ---- (T-Deck only; predicates live in the same
// HAS_TDECK_GT911 build environment as the actual light-sleep execution)
if (g_statusbar.sleep_icon) {
if (!touchSleep::enabled()) {
// Feature off — hide entirely; no status to convey.
lv_obj_add_flag(g_statusbar.sleep_icon, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_clear_flag(g_statusbar.sleep_icon, LV_OBJ_FLAG_HIDDEN);
const char* reason = tsBlockReason();
// Ready = accent colour (same warm highlight used for active glyphs elsewhere
// in the bar). Blocked = muted grey (COLOR_SUB) so it reads as "paused".
lv_obj_set_style_text_color(g_statusbar.sleep_icon,
lv_color_hex(reason == nullptr ? COLOR_ACCENT : (uint32_t)COLOR_SUB),
LV_PART_MAIN);
}
}
// Idle power-save state is now shown by tinting the status-bar battery glyph amber
// (iPhone-style, see applyBattColor), so the separate moon icon stays hidden. Kept in
// the tree (hidden) to preserve the bar layout and its tap handler.
if (g_statusbar.sleep_icon) lv_obj_add_flag(g_statusbar.sleep_icon, LV_OBJ_FLAG_HIDDEN);
#endif
// ---- Mesh signal strength ---- (SNR of the last packet we heard; dims when
@@ -25044,6 +25236,14 @@ static void updateGlobalStatusBar() {
lv_label_set_text(g_statusbar.batt_icon, g);
s_last_glyph = g;
}
#if defined(HAS_TDECK_GT911)
// Re-tint the battery amber/normal when idle power-save toggles (the toggle cb routes through
// here via updateGlobalStatusBar). Only on change, so no per-tick style churn.
{ static int s_last_pwr = -1;
const int pwr = touchSleep::enabled() ? 1 : 0;
if (pwr != s_last_pwr) { s_last_pwr = pwr; applyBattColor(); }
}
#endif
// ---- Clock ----
#if defined(ESP32)
@@ -29865,6 +30065,29 @@ void UITask::newRoomMsgFromPubWithMeta(uint8_t path_len, bool is_flood,
syncThreadMeshSlots(from_name, false);
}
void UITask::appSentMsgToContact(const uint8_t* to_pub, const char* to_name, const char* text,
uint32_t ack_hash) {
if (!to_name || !to_name[0] || !text || !text[0]) return;
const char* sender = (_node_prefs && _node_prefs->node_name[0]) ? _node_prefs->node_name : "me";
// Mirror an app-originated DM as a local outgoing bubble in the recipient's thread (#46) — the
// on-device UI is otherwise the only message consumer that never sees sends made from the
// companion app. appendMessage creates the thread if it doesn't exist and refreshes the view.
appendMessage(to_name, sender, text, false /*channel*/, true /*outgoing*/, false /*mark_unread*/,
ack_hash, DELIV_SENT);
// Lock the thread to the contact's pubkey so a reply typed on-device resolves it (mirrors the
// receive path above).
const int t = findThreadByName(to_name, false);
if (t < 0 || !to_pub) return;
_ui_threads[t].mesh_contact_idx = -1;
memcpy(_ui_threads[t].mesh_contact_pub, to_pub, PUB_KEY_SIZE);
memcpy(_ui_threads[t].mesh_contact_key6, to_pub, 6);
if (!_ui_threads[t].channel && t == _active_thread_idx) {
_active_dm_contact_set = true;
memcpy(_active_dm_contact_pub, to_pub, sizeof(_active_dm_contact_pub));
}
syncThreadMeshSlots(to_name, false);
}
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
newMsgImpl(path_len, from_name, text, msgcount, 0 /*meta_flags*/, 0 /*snr*/, 0 /*rssi*/);
}
+2
View File
@@ -409,6 +409,8 @@ public:
const char* author_name,
const char* text, int msgcount,
int8_t snr_q4, int8_t rssi) override;
void appSentMsgToContact(const uint8_t* to_pub, const char* to_name, const char* text,
uint32_t ack_hash) override;
void notify(UIEventType t = UIEventType::none) override;
void logRxFrame(float snr, float rssi, const uint8_t* raw, int len) override;
void discoveredContact(const ContactInfo& contact, bool is_new, uint8_t path_len) override;