diff --git a/examples/companion_radio/ui-lvgl/AdminScreen.h b/examples/companion_radio/ui-lvgl/AdminScreen.h index b4d81bf8..a2162568 100644 --- a/examples/companion_radio/ui-lvgl/AdminScreen.h +++ b/examples/companion_radio/ui-lvgl/AdminScreen.h @@ -350,21 +350,12 @@ void UITask::adminValuePopup() { void UITask::adminTextPopup(const char* text, bool digits) { AdminSession& S = _core->admin; const admin::Field* f = S.currentField(); - lv_obj_t* panel = navPopupPanel(f ? f->label : "Command", false); - lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard - _nav_ta = textField(panel); - lv_textarea_set_max_length(_nav_ta, 150); - if (digits) lv_textarea_set_accepted_chars(_nav_ta, "0123456789."); - if (f && f->isCustom()) lv_textarea_set_placeholder_text(_nav_ta, "e.g. get advert.interval"); - lv_textarea_set_text(_nav_ta, text); - lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED); - _nav_kb = kb::create(_nav_overlay, _prefs); - lv_obj_set_size(_nav_kb, LV_PCT(100), 124); - lv_obj_align(_nav_kb, LV_ALIGN_BOTTOM_MID, 0, 0); - lv_keyboard_set_textarea(_nav_kb, _nav_ta); - if (digits) kb::apply(_nav_kb, kb::L_SYM); - lv_obj_add_event_cb(_nav_kb, onAdminTextKb, LV_EVENT_READY, NULL); - lv_obj_add_event_cb(_nav_kb, onAdminTextKb, LV_EVENT_CANCEL, NULL); + TextEntry t = { f ? f->label : "Command", onAdminTextKb }; + t.text = text; + t.max_bytes = 150; + if (digits) { t.accepted = "0123456789."; t.symbols = true; } + if (f && f->isCustom()) t.hint = "e.g. get advert.interval"; + navTextEntry(t); } void UITask::adminTextDone(bool ok) { diff --git a/examples/companion_radio/ui-lvgl/BotScreen.h b/examples/companion_radio/ui-lvgl/BotScreen.h index d25cac29..ba14498e 100644 --- a/examples/companion_radio/ui-lvgl/BotScreen.h +++ b/examples/companion_radio/ui-lvgl/BotScreen.h @@ -153,13 +153,11 @@ void UITask::botRow(int row) { int cap; if (char* t = botcfg::text(p, r.kind, cap)) { bool reply = !botcfg::isTrigger(r.kind); - lv_obj_t* panel = navPopupPanel(r.label, false); - lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard - _nav_ta = textField(panel); - lv_textarea_set_max_length(_nav_ta, cap - 1); - lv_textarea_set_text(_nav_ta, t); - lv_textarea_set_placeholder_text(_nav_ta, botcfg::isTrigger(r.kind) ? "e.g. !hi, or * for any message" : "Reply text"); - lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED); + TextEntry te = { r.label, onBotTextKb }; + te.text = t; + te.max_bytes = cap - 1; + te.hint = botcfg::isTrigger(r.kind) ? "e.g. !hi, or * for any message" : "Reply text"; + lv_obj_t* panel = navTextEntry(te); if (reply) { // Placeholders the bot fills in, as buttons in place of the title (no // room for another row above the keyboard; the field says "Reply"). @@ -180,12 +178,6 @@ void UITask::botRow(int row) { if (strcmp(ph, "{batt}") != 0) botChip(ph, c); // already there }, chips); } - _nav_kb = kb::create(_nav_overlay, _prefs); - lv_obj_set_size(_nav_kb, LV_PCT(100), 124); - lv_obj_align(_nav_kb, LV_ALIGN_BOTTOM_MID, 0, 0); - lv_keyboard_set_textarea(_nav_kb, _nav_ta); - lv_obj_add_event_cb(_nav_kb, onBotTextKb, LV_EVENT_READY, NULL); - lv_obj_add_event_cb(_nav_kb, onBotTextKb, LV_EVENT_CANCEL, NULL); return; } diff --git a/examples/companion_radio/ui-lvgl/ConversationScreen.h b/examples/companion_radio/ui-lvgl/ConversationScreen.h index cd416c63..b6d6d775 100644 --- a/examples/companion_radio/ui-lvgl/ConversationScreen.h +++ b/examples/companion_radio/ui-lvgl/ConversationScreen.h @@ -122,18 +122,11 @@ void UITask::roomLoginPopup(const uint8_t* pub_key) { ContactInfo ci; if (!MessageHistory::contactByPrefix(pub_key, ci)) return; memcpy(s_login_key, ci.id.pub_key, PUB_KEY_SIZE); - lv_obj_t* panel = navPopupPanel(ci.name, false); - lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard - _nav_ta = wifiField(panel, "Room password (may be empty)", true); - lv_obj_remove_event_cb(_nav_ta, onWifiField); - lv_textarea_set_max_length(_nav_ta, 15); - lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED); // draws the cursor - _nav_kb = kb::create(_nav_overlay, _prefs); - lv_obj_set_size(_nav_kb, LV_PCT(100), 124); - lv_obj_align(_nav_kb, LV_ALIGN_BOTTOM_MID, 0, 0); - lv_keyboard_set_textarea(_nav_kb, _nav_ta); - lv_obj_add_event_cb(_nav_kb, onRoomLoginKb, LV_EVENT_READY, NULL); - lv_obj_add_event_cb(_nav_kb, onRoomLoginKb, LV_EVENT_CANCEL, NULL); + TextEntry t = { ci.name, onRoomLoginKb }; + t.hint = "Room password (may be empty)"; + t.password = true; + t.max_bytes = 15; + navTextEntry(t); } void UITask::roomLoginDone(bool ok) { diff --git a/examples/companion_radio/ui-lvgl/DeviceScreen.h b/examples/companion_radio/ui-lvgl/DeviceScreen.h index e4790c69..af742902 100644 --- a/examples/companion_radio/ui-lvgl/DeviceScreen.h +++ b/examples/companion_radio/ui-lvgl/DeviceScreen.h @@ -39,14 +39,6 @@ static uint8_t s_pin_key[PUB_KEY_SIZE]; enum : uint8_t { F_CHANGE, F_REMOVE }; -// Text field whose limit is in UTF-8 bytes (user data: the byte cap). -static void onByteCapInsert(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); - size_t cap = (size_t)(uintptr_t)lv_event_get_user_data(e); - if (ins && strlen(lv_textarea_get_text(ta)) + strlen(ins) > cap) lv_textarea_set_insert_replace(ta, ""); -} - } // namespace devview static void onNodeName(lv_event_t* e) { (void)e; s_ui->nodeNamePopup(); } @@ -116,20 +108,11 @@ static bool pinEdit(char* entry, const char* key) { void UITask::nodeNamePopup() { if (!_prefs) return; - lv_obj_t* panel = navPopupPanel("Node name", false); - lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard - _nav_ta = wifiField(panel, "Shown to others in adverts", false); - lv_obj_remove_event_cb(_nav_ta, onWifiField); - lv_textarea_set_text(_nav_ta, the_mesh.getNodeName()); - lv_obj_add_event_cb(_nav_ta, devview::onByteCapInsert, LV_EVENT_INSERT, - (void*)(uintptr_t)(sizeof(_prefs->node_name) - 1)); - lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED); // draws the cursor - _nav_kb = kb::create(_nav_overlay, _prefs); - lv_obj_set_size(_nav_kb, LV_PCT(100), 124); - lv_obj_align(_nav_kb, LV_ALIGN_BOTTOM_MID, 0, 0); - lv_keyboard_set_textarea(_nav_kb, _nav_ta); - lv_obj_add_event_cb(_nav_kb, onNodeNameKb, LV_EVENT_READY, NULL); - lv_obj_add_event_cb(_nav_kb, onNodeNameKb, LV_EVENT_CANCEL, NULL); + TextEntry t = { "Node name", onNodeNameKb }; + t.hint = "Shown to others in adverts"; + t.text = the_mesh.getNodeName(); + t.max_bytes = sizeof(_prefs->node_name) - 1; + navTextEntry(t); } void UITask::nodeNameDone(bool ok) { diff --git a/examples/companion_radio/ui-lvgl/Keyboard.h b/examples/companion_radio/ui-lvgl/Keyboard.h index ad99ac86..9a183a41 100644 --- a/examples/companion_radio/ui-lvgl/Keyboard.h +++ b/examples/companion_radio/ui-lvgl/Keyboard.h @@ -155,6 +155,7 @@ static const char* s_var_map[13]; // on another object) can take that state away: put it back after a pick. static void keepCursor(lv_obj_t* ta) { lv_obj_add_state(ta, LV_STATE_FOCUSED); + lv_textarea_set_cursor_pos(ta, lv_textarea_get_cursor_pos(ta)); // restarts the blink (see fieldFocus) lv_obj_invalidate(ta); } diff --git a/examples/companion_radio/ui-lvgl/MapAreas.h b/examples/companion_radio/ui-lvgl/MapAreas.h index f03ee60a..8babec4d 100644 --- a/examples/companion_radio/ui-lvgl/MapAreas.h +++ b/examples/companion_radio/ui-lvgl/MapAreas.h @@ -452,18 +452,10 @@ void UITask::mapAreaAction(uint8_t act) { bool trails = m.flags & mapview::AreaStore::F_TRAILS; switch (act) { case A_RENAME: { - lv_obj_t* panel = navPopupPanel("Area name", false); - lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard - _nav_ta = textField(panel); - lv_textarea_set_max_length(_nav_ta, sizeof(m.name) - 1); - lv_textarea_set_text(_nav_ta, m.name); - lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED); - _nav_kb = kb::create(_nav_overlay, _prefs); - lv_obj_set_size(_nav_kb, LV_PCT(100), 124); - lv_obj_align(_nav_kb, LV_ALIGN_BOTTOM_MID, 0, 0); - lv_keyboard_set_textarea(_nav_kb, _nav_ta); - lv_obj_add_event_cb(_nav_kb, onRenameKb, LV_EVENT_READY, NULL); - lv_obj_add_event_cb(_nav_kb, onRenameKb, LV_EVENT_CANCEL, NULL); + TextEntry t = { "Area name", onRenameKb }; + t.text = m.name; + t.max_bytes = sizeof(m.name) - 1; + navTextEntry(t); return; } case A_FILL: diff --git a/examples/companion_radio/ui-lvgl/NavMap.h b/examples/companion_radio/ui-lvgl/NavMap.h index e136ab0a..d335c3f3 100644 --- a/examples/companion_radio/ui-lvgl/NavMap.h +++ b/examples/companion_radio/ui-lvgl/NavMap.h @@ -92,15 +92,6 @@ static void onTrailDraw(lv_event_t* e) { lv_draw_line(d.base.layer, &d); } -// Waypoint labels are 11 bytes of UTF-8 (Waypoint.h): reject input past that, -// so a two-byte letter is never cut in half. -static void onLabelInsert(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) > WAYPOINT_LABEL_LEN - 1) - lv_textarea_set_insert_replace(ta, ""); -} - } // namespace navmap static void onNavList(lv_event_t* e) { (void)e; s_ui->navTargetsPopup(); } @@ -603,6 +594,24 @@ void UITask::navClosePopup() { _nav_trail_lbl = _nav_trail_btn = _nav_reset_lbl = _nav_share_lbl = _nav_share_btn = _nav_tb_btn = nullptr; } +lv_obj_t* UITask::navTextEntry(const TextEntry& t) { + lv_obj_t* panel = navPopupPanel(t.title, false); + lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard + _nav_ta = textField(panel, t.hint); + if (t.password) lv_textarea_set_password_mode(_nav_ta, true); + if (t.accepted) lv_textarea_set_accepted_chars(_nav_ta, t.accepted); + if (t.text) lv_textarea_set_text(_nav_ta, t.text); // before the cap: a longer stored text is shown, not wiped + if (t.max_bytes) lv_obj_add_event_cb(_nav_ta, onByteCapInsert, LV_EVENT_INSERT, (void*)(uintptr_t)t.max_bytes); + _nav_kb = kb::create(_nav_overlay, _prefs); + lv_obj_set_size(_nav_kb, LV_PCT(100), 124); + lv_obj_align(_nav_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_keyboard_set_textarea(_nav_kb, _nav_ta); // focuses the field: the cursor + if (t.symbols) kb::apply(_nav_kb, kb::L_SYM); + lv_obj_add_event_cb(_nav_kb, t.done, LV_EVENT_READY, NULL); + lv_obj_add_event_cb(_nav_kb, t.done, LV_EVENT_CANCEL, NULL); + return panel; +} + static void navRowRight(lv_obj_t* row, const char* text, uint32_t col, int right) { lv_obj_align(label(row, text, THEME_FONT_SMALL, col), LV_ALIGN_RIGHT_MID, -right, 0); } @@ -742,19 +751,10 @@ void UITask::navWaypointAction(uint8_t act) { void UITask::navRenamePopup(int idx) { _nav_wp = idx; - lv_obj_t* panel = navPopupPanel("Waypoint name", false); - lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard - _nav_ta = textField(panel); - lv_textarea_set_text(_nav_ta, _core->waypoints.at(idx).label); - lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED); // draws the cursor - lv_obj_add_event_cb(_nav_ta, navmap::onLabelInsert, LV_EVENT_INSERT, NULL); - - _nav_kb = kb::create(_nav_overlay, _prefs); - lv_obj_set_size(_nav_kb, LV_PCT(100), 124); - lv_obj_align(_nav_kb, LV_ALIGN_BOTTOM_MID, 0, 0); - lv_keyboard_set_textarea(_nav_kb, _nav_ta); - lv_obj_add_event_cb(_nav_kb, onNavRenameKb, LV_EVENT_READY, NULL); - lv_obj_add_event_cb(_nav_kb, onNavRenameKb, LV_EVENT_CANCEL, NULL); + TextEntry t = { "Waypoint name", onNavRenameKb }; + t.text = _core->waypoints.at(idx).label; + t.max_bytes = WAYPOINT_LABEL_LEN - 1; + navTextEntry(t); } void UITask::navRenameDone(bool ok) { @@ -1460,19 +1460,10 @@ void UITask::navWaypointsPopup() { // name is optional) -> a new waypoint. void UITask::navCoordsPopup() { if (_core->waypoints.full()) { showToast(waypointsFull()); return; } - lv_obj_t* panel = navPopupPanel("Add by coordinates", false); - lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard - _nav_ta = textField(panel); - lv_textarea_set_placeholder_text(_nav_ta, "50.06142, 19.93721 Name"); - lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED); // draws the cursor - - _nav_kb = kb::create(_nav_overlay, _prefs); - lv_obj_set_size(_nav_kb, LV_PCT(100), 124); - lv_obj_align(_nav_kb, LV_ALIGN_BOTTOM_MID, 0, 0); - lv_keyboard_set_textarea(_nav_kb, _nav_ta); - kb::apply(_nav_kb, kb::L_SYM); // digits first; "abc" gives letters for the name - lv_obj_add_event_cb(_nav_kb, onNavCoordsKb, LV_EVENT_READY, NULL); - lv_obj_add_event_cb(_nav_kb, onNavCoordsKb, LV_EVENT_CANCEL, NULL); + TextEntry t = { "Add by coordinates", onNavCoordsKb }; + t.hint = "50.06142, 19.93721 Name"; + t.symbols = true; // digits first; "abc" gives letters for the name + navTextEntry(t); } void UITask::navCoordsDone(bool ok) { diff --git a/examples/companion_radio/ui-lvgl/QuickScreen.h b/examples/companion_radio/ui-lvgl/QuickScreen.h index dc002f44..edfd0932 100644 --- a/examples/companion_radio/ui-lvgl/QuickScreen.h +++ b/examples/companion_radio/ui-lvgl/QuickScreen.h @@ -151,7 +151,7 @@ void UITask::quickEdit(int slot) { lv_obj_set_width(_nav_ta, w); lv_obj_set_pos(_nav_ta, 8, theme::STATUS_H + 4); lv_textarea_set_text(_nav_ta, msgtext::quick(_prefs, slot)); - lv_obj_add_event_cb(_nav_ta, devview::onByteCapInsert, LV_EVENT_INSERT, (void*)(uintptr_t)(msgtext::QUICK_LEN - 1)); + lv_obj_add_event_cb(_nav_ta, onByteCapInsert, LV_EVENT_INSERT, (void*)(uintptr_t)(msgtext::QUICK_LEN - 1)); lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED); // draws the cursor lv_obj_t* row = chips(_nav_overlay, onQuickEditPh, false); diff --git a/examples/companion_radio/ui-lvgl/RadioExtras.h b/examples/companion_radio/ui-lvgl/RadioExtras.h index 899ea0ef..c01d7449 100644 --- a/examples/companion_radio/ui-lvgl/RadioExtras.h +++ b/examples/companion_radio/ui-lvgl/RadioExtras.h @@ -112,19 +112,11 @@ void UITask::radioNamePopup(uint8_t what) { const char* title = what == N_PRESET ? "Preset name" : what == N_SCOPE_ADD ? "New scope" : "Scope name"; const char* hint = what == N_PRESET ? "e.g. Hiking" : "Region name, e.g. pl-maz"; size_t cap = what == N_PRESET ? sizeof(_prefs->user_radio_presets[0].name) - 1 : sizeof(ScopeEntry::name) - 1; - lv_obj_t* panel = navPopupPanel(title, false); - lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard - _nav_ta = wifiField(panel, hint, false); - lv_obj_remove_event_cb(_nav_ta, onWifiField); - if (what == N_SCOPE_RENAME) lv_textarea_set_text(_nav_ta, the_mesh.scopeList().name((uint8_t)s_scope)); - lv_obj_add_event_cb(_nav_ta, devview::onByteCapInsert, LV_EVENT_INSERT, (void*)(uintptr_t)cap); - lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED); // draws the cursor - _nav_kb = kb::create(_nav_overlay, _prefs); - lv_obj_set_size(_nav_kb, LV_PCT(100), 124); - lv_obj_align(_nav_kb, LV_ALIGN_BOTTOM_MID, 0, 0); - lv_keyboard_set_textarea(_nav_kb, _nav_ta); - lv_obj_add_event_cb(_nav_kb, onRadioNameKb, LV_EVENT_READY, NULL); - lv_obj_add_event_cb(_nav_kb, onRadioNameKb, LV_EVENT_CANCEL, NULL); + TextEntry t = { title, onRadioNameKb }; + t.hint = hint; + t.max_bytes = cap; + if (what == N_SCOPE_RENAME) t.text = the_mesh.scopeList().name((uint8_t)s_scope); + navTextEntry(t); } void UITask::radioNameDone(bool ok) { diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 9c13b8df..ccf098e8 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -1056,6 +1056,15 @@ static void fieldFocus(lv_obj_t* ta, bool on) { lv_textarea_set_cursor_pos(ta, lv_textarea_get_cursor_pos(ta)); } +// A field limited in UTF-8 bytes (user data: the cap) -- names and other text +// that is stored in a fixed buffer. LVGL's own limit counts characters. +static void onByteCapInsert(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); + size_t cap = (size_t)(uintptr_t)lv_event_get_user_data(e); + if (ins && strlen(lv_textarea_get_text(ta)) + strlen(ins) > cap) lv_textarea_set_insert_replace(ta, ""); +} + // The primary action of a screen or popup: full amber, dark text (Theme.h); // call after its label is made. static void stylePrimary(lv_obj_t* b) { diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index ac370f0b..866cc616 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -24,6 +24,20 @@ struct UiEvent; namespace mapview { struct TileArea; } +// A popup that asks for one line of text: the field over the keyboard. `done` +// gets the keyboard's READY (OK) and CANCEL; the field is _nav_ta, the keyboard +// _nav_kb, until navClosePopup(). +struct TextEntry { + const char* title; + lv_event_cb_t done; + const char* text = nullptr; // what the field starts with + const char* hint = nullptr; // placeholder + size_t max_bytes = 0; // UTF-8 bytes the field takes; 0 = no limit + bool password = false; + const char* accepted = nullptr; // only these characters (lv_textarea_set_accepted_chars) + bool symbols = false; // open on the digits / symbols page +}; + class UITask : public UITaskBase, public UiCoreHost { public: UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : UITaskBase(board, serial) {} @@ -385,6 +399,7 @@ private: void navFrameTarget(); void navSetTarget(uint8_t kind, const uint8_t* key, int32_t lat, int32_t lon, const char* name); lv_obj_t* navPopupPanel(const char* title, bool full, bool bottom = false); + lv_obj_t* navTextEntry(const TextEntry& t); // popup + one-line field + keyboard (NavMap.h) void navRenamePopup(int idx); void navAddWaypoint(int32_t lat, int32_t lon); void navDropAt(int x, int y); diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index bb8e1b39..e6ab8fd4 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -562,6 +562,83 @@ class MessagesScreen : public UIScreen { return { outgoing ? (full_avail - w) : 0, w }; } + // The marker in a bubble's header, after the sender: a delivery glyph (an + // outgoing DM, or a channel post a repeater echoed), or the hop count an + // incoming message took; none if neither applies. + struct BubbleMark { + enum Kind : uint8_t { NONE, ACK, HOPS } kind = NONE; + AckState status = ACK_OK; + int sends = 1, relay = 0, hops = 0; + static BubbleMark ack(AckState s, int sends, int relay) { BubbleMark m; m.kind = ACK; m.status = s; m.sends = sends; m.relay = relay; return m; } + static BubbleMark hop(int n) { BubbleMark m; if (n > 0) { m.kind = HOPS; m.hops = n; } return m; } + int width(DisplayDriver& d) const { + return kind == ACK ? 3 + ackGlyphWidth(d, status, sends, relay) + : kind == HOPS ? 3 + miniIconNumberWidth(d, hops) : 0; + } + }; + + // One history row as a bubble with a header (sender, marker, age) over its + // body, sized to its own content -- shared by the DM/room and channel lists. + // `expand` wraps the body over several lines; otherwise one line that the + // selected row marquees. `mq_delay` takes the marquee's redraw request. + void drawHeadedBubble(DisplayDriver& display, int y, int bh, int lh, bool sel, int reserve, bool outgoing, + const char* sender, const char* body, const char* age, const BubbleMark& mark, + bool expand, int& mq_delay) { + const int age_w = age[0] ? display.getTextWidth(age) + 3 : 0; + // Size the bubble to its own content before drawing anything (see + // computeBubbleBox): the header (sender+marker+age) vs the body, measured + // once here and reused below instead of re-wrapping. + const int full_avail = display.width() - reserve; + const int max_w = bubbleMaxW(display, full_avail); + const int ack_w = mark.width(display); + // A little air between a marker and the age, so "12" + "1s" doesn't read as "121s". + const int mk_gap = (ack_w > 0 && age_w > 0) ? 2 : 0; + const int header_w = 3 + (sender[0] ? display.getTextWidth(sender) : -3) + ack_w + mk_gap + age_w + 3; + int body_w, nl = 0; + if (expand) { + display.translateUTF8ToBlocks(s_wrap_trans, body, sizeof(s_wrap_trans)); + nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, max_w - 6, s_wrap_lines, 8); + body_w = 0; + for (int li = 0; li < nl; li++) { int w = display.getTextWidth(s_wrap_lines[li]); if (w > body_w) body_w = w; } + body_w += 6; + } else { + int raw_w = display.getTextWidth(body); + body_w = (raw_w > max_w - 6 ? max_w - 6 : raw_w) + 6; + } + const BubbleBox box = computeBubbleBox(full_avail, max_w, outgoing, header_w, body_w); + + drawHistRowFrame(display, box.x, box.w, y, bh, lh, sel); + // Only the body marquees, not the sender too: both share the single + // marquee slot on DisplayDriver, and if two texts in the same row both + // qualified they'd keep resetting each other's animation every frame. + // The name gives way to the marker and the age (a long name is + // ellipsized), and the marker sits right after whatever width the name + // actually got -- not after its full width, which would run it into the age. + int name_avail = box.w - 6 - age_w - ack_w - mk_gap; + if (name_avail < display.getCharWidth()) name_avail = display.getCharWidth(); + int name_w = sender[0] ? display.getTextWidth(sender) : 0; + if (name_w > name_avail) name_w = name_avail; + if (sender[0]) display.drawTextEllipsized(box.x + 3, y + 1, name_avail, sender); + int gx = box.x + 3 + (name_w ? name_w + 3 : 0); + if (mark.kind == BubbleMark::ACK) drawAckGlyph(display, gx, y + 1, mark.status, mark.sends, mark.relay); + else if (mark.kind == BubbleMark::HOPS) miniIconDrawNumber(display, gx, y + 1, mark.hops); + if (age[0]) { display.setCursor(box.x + box.w - age_w, y + 1); display.print(age); } + // Ink is already LIGHT (unselected) or DARK (selected) from + // drawHistRowFrame above, and nothing since has changed it. + if (expand) { + for (int li = 0; li < nl; li++) { display.setCursor(box.x + 3, y + (li + 1) * lh + 1); display.print(s_wrap_lines[li]); } + } else { + // Suppress this row's own marquee while _ctx_menu (Path/Relayed by, + // etc.) sits on top of it -- both would otherwise fight over + // DisplayDriver's single shared marquee slot every frame (this row + // redraws every frame regardless of the popup), each is_new-resetting + // the other's animation and producing a stuck-then-jumpy scroll. + const bool body_marquee = sel && !_ctx_menu.active; + const int r_body = display.drawTextEllipsized(box.x + 3, y + lh + 1, box.w - 6, body, body_marquee); + if (body_marquee && r_body > 0) mq_delay = r_body; + } + } + // Bottom-right "+ Send" compose button, shared by both history lists: // bordered when idle, inverted (filled) when selected. Right-aligned to // match the outgoing ("Me") bubbles anchored on that side, so composing @@ -1511,71 +1588,12 @@ public: // time at write time), but if it ever does, show the receipt time // (now_ts) rather than leave the row with no age at all. char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, e.timestamp ? e.timestamp : now_ts); - int age_w = age[0] ? display.getTextWidth(age) + 3 : 0; - - // Size the bubble to its own content before drawing anything (see - // computeBubbleBox): the header (sender+ack+age) vs the body, measured - // once here and reused below instead of re-wrapping. - int full_avail = display.width() - reserve; - int max_w = bubbleMaxW(display, full_avail); - // Incoming: the hop count the DM actually took to reach us, shown as - // the same tiny digit icon an outgoing send uses for its relay/echo - // count -- no ack glyph exists for incoming (there's nothing to - // deliver), so this slot is otherwise empty. - int in_hop_count = !e.outgoing ? (e.path_len & 63) : 0; - int ack_w = e.outgoing ? (3 + ackGlyphWidth(display, _history.dmEffectiveStatus(e), e.attempt + 1)) - : (in_hop_count > 0 ? (3 + miniIconNumberWidth(display, in_hop_count)) : 0); - // A little air between a marker and the age, so "12" + "1s" doesn't read as "121s". - int mk_gap = (ack_w > 0 && age_w > 0) ? 2 : 0; - int header_w = 3 + (sender[0] ? display.getTextWidth(sender) : -3) + ack_w + mk_gap + age_w + 3; - int body_w, nl = 0; - if (expand) { - display.translateUTF8ToBlocks(s_wrap_trans, body, sizeof(s_wrap_trans)); - nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, max_w - 6, s_wrap_lines, 8); - body_w = 0; - for (int li = 0; li < nl; li++) { int w = display.getTextWidth(s_wrap_lines[li]); if (w > body_w) body_w = w; } - body_w += 6; - } else { - int raw_w = display.getTextWidth(body); - body_w = (raw_w > max_w - 6 ? max_w - 6 : raw_w) + 6; - } - BubbleBox box = computeBubbleBox(full_avail, max_w, e.outgoing, header_w, body_w); - - drawHistRowFrame(display, box.x, box.w, y, bh, lh, sel); - // Only the body marquees, not the sender too: both share the single - // marquee slot on DisplayDriver, and if two texts in the same row both - // qualified they'd keep resetting each other's animation every frame. - // The name gives way to the ack/hop marker and the age (a long name is - // ellipsized), and the marker sits right after whatever width the name - // actually got -- not after its full width, which would run it into the age. - int name_avail = box.w - 6 - age_w - ack_w - mk_gap; - if (name_avail < display.getCharWidth()) name_avail = display.getCharWidth(); - int name_w = sender[0] ? display.getTextWidth(sender) : 0; - if (name_w > name_avail) name_w = name_avail; - if (sender[0]) display.drawTextEllipsized(box.x + 3, y + 1, name_avail, sender); - const int mark_x = box.x + 3 + (name_w ? name_w + 3 : 0); - if (e.outgoing) { // delivery marker after "Me" - int gx = mark_x; - drawAckGlyph(display, gx, y + 1, _history.dmEffectiveStatus(e), e.attempt + 1); - } else if (in_hop_count > 0) { // hop count after the sender name - int gx = mark_x; - miniIconDrawNumber(display, gx, y + 1, in_hop_count); - } - if (age[0]) { display.setCursor(box.x + box.w - age_w, y + 1); display.print(age); } - // Ink is already LIGHT (unselected) or DARK (selected) from - // drawHistRowFrame above, and nothing since has changed it. - if (expand) { - for (int li = 0; li < nl; li++) { display.setCursor(box.x + 3, y + (li + 1) * lh + 1); display.print(s_wrap_lines[li]); } - } else { - // Suppress this row's own marquee while _ctx_menu (Path/Relayed by, - // etc.) sits on top of it -- both would otherwise fight over - // DisplayDriver's single shared marquee slot every frame (this row - // redraws every frame regardless of the popup), each is_new-resetting - // the other's animation and producing a stuck-then-jumpy scroll. - bool body_marquee = sel && !_ctx_menu.active; - int r_body = display.drawTextEllipsized(box.x + 3, y + lh + 1, box.w - 6, body, body_marquee); - if (body_marquee && r_body > 0) mq_delay = r_body; - } + // Outgoing: the delivery marker. Incoming: the hop count the DM + // actually took to reach us, as the same tiny digit icon (no ack + // glyph exists for incoming -- there's nothing to deliver). + const BubbleMark mark = e.outgoing ? BubbleMark::ack(_history.dmEffectiveStatus(e), e.attempt + 1, 0) + : BubbleMark::hop(e.path_len & 63); + drawHeadedBubble(display, y, bh, lh, sel, reserve, e.outgoing, sender, body, age, mark, expand, mq_delay); } if (dm_count == 0) { @@ -1734,70 +1752,18 @@ public: // now_ts here too rather than leave the row with no age at all. uint32_t ch_ts = _history.chAtPos(ring_pos).timestamp; char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, ch_ts ? ch_ts : now_ts); - int age_w = age[0] ? display.getTextWidth(age) + 3 : 0; const char* body = skipReplyPrefix(msg_part); - // Size the bubble to its own content before drawing anything (see - // computeBubbleBox): the header (sender+ack+age) vs the body, measured - // once here and reused below instead of re-wrapping. Channels have no - // recipient ACK — only show ✓ once a repeater echo confirms the send - // was relayed into the mesh; otherwise no marker (absence is normal). - bool outgoing = strcmp(sender, "Me") == 0; - bool show_ack = outgoing && _history.chAtPos(ring_pos).relay_status == ACK_OK; - int relay_count = show_ack ? (_history.chAtPos(ring_pos).path_len & 63) : 0; - // Incoming: the hop count this post actually took to reach us, shown - // the same way an outgoing post's repeater-echo count is. - int in_hop_count = !outgoing ? (_history.chAtPos(ring_pos).path_len & 63) : 0; - int full_avail = display.width() - reserve; - int max_w = bubbleMaxW(display, full_avail); - int ack_w = show_ack ? (3 + ackGlyphWidth(display, ACK_OK, 1, relay_count)) - : (in_hop_count > 0 ? (3 + miniIconNumberWidth(display, in_hop_count)) : 0); - // A little air between a marker and the age, so "12" + "1s" doesn't read as "121s". - int mk_gap = (ack_w > 0 && age_w > 0) ? 2 : 0; - int header_w = 3 + (sender[0] ? display.getTextWidth(sender) : -3) + ack_w + mk_gap + age_w + 3; - int body_w, nl = 0; - if (expand) { - display.translateUTF8ToBlocks(s_wrap_trans, body, sizeof(s_wrap_trans)); - nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, max_w - 6, s_wrap_lines, 8); - body_w = 0; - for (int li = 0; li < nl; li++) { int w = display.getTextWidth(s_wrap_lines[li]); if (w > body_w) body_w = w; } - body_w += 6; - } else { - int raw_w = display.getTextWidth(body); - body_w = (raw_w > max_w - 6 ? max_w - 6 : raw_w) + 6; - } - BubbleBox box = computeBubbleBox(full_avail, max_w, outgoing, header_w, body_w); - - drawHistRowFrame(display, box.x, box.w, y, bh, lh, sel); - // Only the body marquees, not the sender — see the DM history block above. - // Name yields to the marker and age -- see the DM history block above. - int name_avail = box.w - 6 - age_w - ack_w - mk_gap; - if (name_avail < display.getCharWidth()) name_avail = display.getCharWidth(); - int name_w = display.getTextWidth(sender); - if (name_w > name_avail) name_w = name_avail; - display.drawTextEllipsized(box.x + 3, y + 1, name_avail, sender); - if (show_ack) { - int gx = box.x + 3 + name_w + 3; - drawAckGlyph(display, gx, y + 1, ACK_OK, 1, relay_count); - } else if (in_hop_count > 0) { - int gx = box.x + 3 + name_w + 3; - miniIconDrawNumber(display, gx, y + 1, in_hop_count); - } - if (age[0]) { display.setCursor(box.x + box.w - age_w, y + 1); display.print(age); } - // Ink is already LIGHT (unselected) or DARK (selected) from - // drawHistRowFrame above, and nothing since has changed it. - if (expand) { - for (int li = 0; li < nl; li++) { display.setCursor(box.x + 3, y + (li + 1) * lh + 1); display.print(s_wrap_lines[li]); } - } else { - // Suppress this row's own marquee while _ctx_menu (Path/Relayed by, - // etc.) sits on top of it -- both would otherwise fight over - // DisplayDriver's single shared marquee slot every frame (this row - // redraws every frame regardless of the popup), each is_new-resetting - // the other's animation and producing a stuck-then-jumpy scroll. - bool body_marquee = sel && !_ctx_menu.active; - int r_body = display.drawTextEllipsized(box.x + 3, y + lh + 1, box.w - 6, body, body_marquee); - if (body_marquee && r_body > 0) mq_delay = r_body; - } + // Channels have no recipient ACK -- only show the glyph once a + // repeater echo confirms the send was relayed into the mesh; otherwise + // no marker (absence is normal). Incoming: the hop count this post + // actually took to reach us. + const bool outgoing = strcmp(sender, "Me") == 0; + const ChHistEntry& cm = _history.chAtPos(ring_pos); + const BubbleMark mark = !outgoing ? BubbleMark::hop(cm.path_len & 63) + : cm.relay_status == ACK_OK ? BubbleMark::ack(ACK_OK, 1, cm.path_len & 63) + : BubbleMark(); + drawHeadedBubble(display, y, bh, lh, sel, reserve, outgoing, sender, body, age, mark, expand, mq_delay); } if (ch_hist_count == 0) { diff --git a/src/helpers/sensors/GpsSky.h b/src/helpers/sensors/GpsSky.h index 0e861b11..3bd47211 100644 --- a/src/helpers/sensors/GpsSky.h +++ b/src/helpers/sensors/GpsSky.h @@ -56,6 +56,10 @@ public: _len = 0; } + // The receiver was switched off: a search that never ended stops counting + // (a fix already found keeps its time-to-first-fix to look at). + void halt() { if (!ttff_ms) start_ms = 0; } + void feed(char c) { if (c == '$') { _len = 0; _buf[_len++] = c; return; } if (_len == 0) return; diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index 99f1d3af..588b21af 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -105,7 +105,7 @@ public : void stop() override { #ifdef GPS_SKYVIEW - _sky.reset(); // a receiver that is off is not searching: stop the TTFF clock + _sky.halt(); // a receiver that is off is not searching: stop the search clock #endif if (_pin_en != -1) { digitalWrite(_pin_en, !GPS_EN_ACTIVE);