feat(nav): navigate to / save a location shared in a message

Locations already travel as plain text ({loc} expands to "lat,lon"), so any
message carrying coordinates can now be acted on. In the fullscreen message
view, the context-menu (Hold Enter) gains Navigate and Save waypoint
entries whenever the message contains a coordinate — alongside Reply.

- geo::parseLatLon: shared scanner for an embedded "lat,lon" (the {loc}
  format), with an optional [WAY] tag that supplies a label. Range-checked,
  requires a decimal point to avoid matching plain integer pairs. Will also
  back the waypoint-share feature later.
- QuickMsgScreen: Navigate opens NavView inline (To/Hdg/distance, honouring
  the global Units setting) over the message, back returns to it; Save
  waypoint stores the point (label from [WAY], else auto-named). Works in
  both DM and channel histories, and on incoming or outgoing messages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-06-04 11:28:17 +02:00
parent ad89104599
commit 35d6e8b1a3
2 changed files with 143 additions and 13 deletions

View File

@@ -6,6 +6,9 @@
#include <Arduino.h>
#include <math.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
@@ -58,4 +61,52 @@ static inline void fmtDist(char* buf, int n, float km, bool imperial) {
}
}
// Tag marking a shared waypoint inside a message: "[WAY]<lat>,<lon> <label>".
// A plain {loc} expansion ("<lat>,<lon>") parses too — the tag just adds intent
// and a label, and keeps the text readable on apps/firmware that don't know it.
#define WAYPOINT_MSG_TAG "[WAY]"
// Scan a message for an embedded "lat,lon" location (decimal degrees, the same
// text {loc} emits). If a WAYPOINT_MSG_TAG precedes it, the trailing text is
// taken as the label. Returns true and fills lat/lon (1e6-scaled) on a valid,
// in-range coordinate; label (optional) gets the trimmed tag suffix or "".
static inline bool parseLatLon(const char* text, int32_t& lat_1e6, int32_t& lon_1e6,
char* label = nullptr, int label_n = 0) {
if (label && label_n > 0) label[0] = '\0';
if (!text) return false;
const char* tag = strstr(text, WAYPOINT_MSG_TAG);
const char* scan = tag ? tag + strlen(WAYPOINT_MSG_TAG) : text;
for (const char* p = scan; *p; p++) {
if (*p != '-' && *p != '+' && *p != '.' && !isdigit((unsigned char)*p)) continue;
char* end = nullptr;
double la = strtod(p, &end);
if (end == p) continue;
if (!memchr(p, '.', end - p)) continue; // require a decimal point (skip "5,6")
const char* q = end;
while (*q == ' ') q++;
if (*q != ',') { p = end - 1; continue; }
q++;
while (*q == ' ') q++;
char* end2 = nullptr;
double lo = strtod(q, &end2);
if (end2 == q) continue;
if (la < -90.0 || la > 90.0 || lo < -180.0 || lo > 180.0) continue;
lat_1e6 = (int32_t)lroundf((float)(la * 1e6));
lon_1e6 = (int32_t)lroundf((float)(lo * 1e6));
if (label && label_n > 0 && tag) { // label only from a tagged share
const char* s = end2;
while (*s == ' ') s++;
int n = 0;
while (s[n] && n < label_n - 1) { label[n] = s[n]; n++; }
while (n > 0 && label[n - 1] == ' ') n--; // trim trailing spaces
label[n] = '\0';
}
return true;
}
return false;
}
} // namespace geo

View File

@@ -2,6 +2,8 @@
// Custom screen — not part of upstream UITask.cpp
// Included by UITask.cpp after SettingsScreen.h is defined.
#include "NavView.h" // navigate to a location shared inside a message
class QuickMsgScreen : public UIScreen {
UITask* _task;
@@ -58,6 +60,18 @@ class QuickMsgScreen : public UIScreen {
char _reply_prefix[36]; // "@[nick] " built when reply is triggered
bool _reply_mode; // true while composing a reply (prefix is prepended)
// Fullscreen-message context menu actions. Built per-message: Reply (when
// applicable) plus Navigate / Save waypoint when the message carries a
// location (a {loc} string or a [WAY] share). _fs_act maps each visible row
// back to an action so the index math survives the conditional layout.
enum FsAct : uint8_t { FS_REPLY, FS_NAV, FS_SAVE };
uint8_t _fs_act[3];
int _fs_act_n = 0;
// Inline navigate-to-location view layered over the fullscreen message.
bool _nav_active = false;
int32_t _nav_lat = 0, _nav_lon = 0;
char _nav_label[24] = "";
struct ChHistEntry { uint8_t ch_idx; char text[140]; uint32_t timestamp; };
ChHistEntry _hist[CH_HIST_MAX];
int _hist_head, _hist_count;
@@ -121,6 +135,64 @@ class QuickMsgScreen : public UIScreen {
_phase = MSG_PICK;
}
// Build the fullscreen-message options popup: Reply (if allowed) plus
// Navigate / Save waypoint when `body` carries a location. Opens _ctx_menu
// only when there's at least one action. Parses the location once here and
// stashes it for the action handler.
void buildFsMenu(const char* body, bool reply_allowed) {
bool has_loc = geo::parseLatLon(body, _nav_lat, _nav_lon, _nav_label, sizeof(_nav_label));
int n = (reply_allowed ? 1 : 0) + (has_loc ? 2 : 0);
if (n == 0) return;
_fs_act_n = 0;
_ctx_menu.begin("Options", n);
if (reply_allowed) { _ctx_menu.addItem("Reply"); _fs_act[_fs_act_n++] = FS_REPLY; }
if (has_loc) { _ctx_menu.addItem("Navigate"); _fs_act[_fs_act_n++] = FS_NAV;
_ctx_menu.addItem("Save waypoint"); _fs_act[_fs_act_n++] = FS_SAVE; }
}
// Dispatch the selected fullscreen-options row. `channel` picks which
// fullscreen view to close when starting a reply.
void dispatchFsAction(bool channel) {
FsAct a = (FsAct)_fs_act[(_ctx_menu._sel >= 0 && _ctx_menu._sel < _fs_act_n) ? _ctx_menu._sel : 0];
_ctx_menu.active = false;
if (a == FS_REPLY) {
(channel ? _fs : _dm_fs).active = false;
startReply(channel);
} else if (a == FS_NAV) {
_nav_active = true; // keep the message view active underneath
} else {
saveSharedWaypoint();
}
}
// Save the location parsed from the open message as a waypoint. Uses the
// [WAY] label when present, else auto-names it like a manually-marked point.
void saveSharedWaypoint() {
if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; }
char label[WAYPOINT_LABEL_LEN];
if (_nav_label[0]) {
strncpy(label, _nav_label, sizeof(label) - 1);
label[sizeof(label) - 1] = '\0';
} else {
snprintf(label, sizeof(label), "WP%d", _task->waypoints().count() + 1);
}
if (_task->waypoints().add(_nav_lat, _nav_lon,
(uint32_t)rtc_clock.getCurrentTime(), label)) {
_task->saveWaypoints();
_task->showAlert("Waypoint saved", 800);
} else {
_task->showAlert("Waypoints full", 1000);
}
}
void renderNav(DisplayDriver& display) {
int32_t mylat, mylon; bool have = _task->currentLocation(mylat, mylon);
int cog; bool cogv = _task->currentCourse(cog);
NodePrefs* p = _task->getNodePrefs();
navview::draw(display, have, mylat, mylon, _nav_lat, _nav_lon,
_nav_label[0] ? _nav_label : "Msg loc", cogv, cog, p && p->units_imperial);
}
void setupMsgPick() {
_msg_sel = _msg_scroll = 0;
_active_msg_count = 0;
@@ -484,6 +556,7 @@ public:
_ctx_menu.active = false;
_ctx_dirty = false;
_nav_active = false;
_pin_picker_active = false;
_dm_direct_entry = false;
_unread_at_entry = 0;
@@ -535,6 +608,9 @@ public:
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
// Navigate-to-location view sits over everything else while active.
if (_nav_active) { renderNav(display); return 1000; }
int lh = display.getLineHeight();
int item_h = display.lineStep();
int start_y = display.listStart();
@@ -985,6 +1061,11 @@ public:
}
bool handleInput(char c) override {
// Navigate view: any back key returns to the message it was opened from.
if (_nav_active) {
if (c == KEY_CANCEL || c == KEY_ENTER || c == KEY_CONTEXT_MENU) _nav_active = false;
return true;
}
if (_phase == MODE_SELECT) {
// Context menu (Mark-all-read) takes precedence while active.
if (_ctx_menu.active) {
@@ -1268,8 +1349,7 @@ public:
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
_dm_fs.active = false;
startReply(false);
dispatchFsAction(false);
} else if (res != PopupMenu::NONE) {
_ctx_menu.active = false;
}
@@ -1284,11 +1364,13 @@ public:
_dm_fs.active = false;
} else if (res == FullscreenMsgView::REPLY) {
int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel);
if (ring_pos >= 0 && !_dm_hist[ring_pos].outgoing) {
char _tname[32]; DisplayDriver::translateUTF8Static(_tname, _sel_contact.name, sizeof(_tname));
snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", _tname);
_ctx_menu.begin("Options", 1);
_ctx_menu.addItem("Reply");
if (ring_pos >= 0) {
bool reply_ok = !_dm_hist[ring_pos].outgoing;
if (reply_ok) {
char _tname[32]; DisplayDriver::translateUTF8Static(_tname, _sel_contact.name, sizeof(_tname));
snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", _tname);
}
buildFsMenu(_dm_hist[ring_pos].text, reply_ok);
}
}
return true;
@@ -1356,8 +1438,7 @@ public:
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
_fs.active = false;
startReply(true);
dispatchFsAction(true);
} else if (res != PopupMenu::NONE) {
_ctx_menu.active = false;
}
@@ -1372,10 +1453,8 @@ public:
_fs.active = false;
} else if (res == FullscreenMsgView::REPLY) {
int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel);
if (ring_pos >= 0 && buildChannelReplyPrefix(_hist[ring_pos].text)) {
_ctx_menu.begin("Options", 1);
_ctx_menu.addItem("Reply");
}
if (ring_pos >= 0)
buildFsMenu(_hist[ring_pos].text, buildChannelReplyPrefix(_hist[ring_pos].text));
}
return true;
}