refactor: shared text-entry popup (ui-lvgl) and chat bubble row (ui-new); GPS search clock keeps a found fix

- ui-lvgl: navTextEntry()/TextEntry replace eight copies of popup + field +
  keyboard wiring; one byte-capped insert callback instead of two.
- ui-new: drawHeadedBubble()/BubbleMark replace the duplicated DM and channel
  bubble drawing in MessagesScreen.
- GpsSky::halt(): stopping the receiver ends an unfinished search but keeps
  the first-fix time; keyboard cursor blink restarts after a key.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VeBrxKyHHxWsmttVkUdBrj
This commit is contained in:
Claude
2026-10-05 07:23:56 +00:00
parent 997da95ecf
commit 84fd5013b5
14 changed files with 180 additions and 251 deletions
+6 -15
View File
@@ -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) {
+5 -13
View File
@@ -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;
}
@@ -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) {
@@ -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) {
@@ -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);
}
+4 -12
View File
@@ -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:
+26 -35
View File
@@ -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) {
@@ -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);
+5 -13
View File
@@ -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) {
@@ -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) {
+15
View File
@@ -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);
+93 -127
View File
@@ -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) {
+4
View File
@@ -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;
@@ -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);