feat(route): path editor with out-and-back trace and a kept route; map markers as one drawn layer

Core: PathEditor (UiCore::route) loads, edits, traces and saves the route to
a contact; MyMesh::sendTrace over an explicit route with per-hop SNRs,
setContactPath / setContactPinned. A route can be kept
(CONTACT_FLAG_ROUTE_PINNED, ContactInfo::flags bit 4): a path heard from the
node doesn't replace it and the last DM resend doesn't drop it.

ui-lvgl: Route screen (node detail), repeater picker by distance, picking
hops by tapping the nodes map (numbered badges, line, Undo / Done). The
map's markers are one drawn layer with hit-testing and name decluttering
instead of three objects per node.

ui-new: Path / trace view in the Nodes action menu; Add reuses the list
filtered to repeaters.

First cut: built for L2 LVGL, L1 OLED and L1 e-ink, tried on L2 only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-13 10:18:05 +02:00
co-authored by Claude Sonnet 5.5
parent c180814960
commit 140ea7c008
17 changed files with 1306 additions and 76 deletions
+193 -2
View File
@@ -5,6 +5,140 @@
#include "NavView.h"
#include "TabBar.h"
// ── Path (Nodes > a contact > Path) ───────────────────────────────────────────
// The repeaters between this node and a contact as a list: edit it, trace it
// out and back (the SNR of every hop), save it as the contact's route. The
// list, the trace and the save are the UI Core's PathEditor (`core().route`);
// ui-lvgl's Route screen is a view over the same. Picking a repeater to add
// hands the Nodes list over for a moment (NearbyScreen: `_route_pick`).
class RouteView {
UITask* _task = nullptr;
bool _active = false;
bool _pick = false; // the Add row was chosen: NearbyScreen takes over the pick
int _sel = 0, _scroll = 0;
PopupMenu _menu; // one hop: up / down / remove
int _menu_hop = 0;
PathEditor& R() { return _task->core().route; }
// Rows: the hops, Add, Trace, Save, Keep.
int rows() { return R().count() + 4; }
int addRow() { return R().count(); }
void openHopMenu(int i) {
_menu_hop = i;
_menu.begin("Hop", 3);
_menu.addItem("Move up");
_menu.addItem("Move down");
_menu.addItem("Remove");
}
public:
bool active() const { return _active; }
bool takePick() { bool p = _pick; _pick = false; return p; }
bool open(UITask* task, const uint8_t* pub_key) {
_task = task;
if (!task->core().route.open(pub_key)) return false;
_active = true;
_pick = false;
_sel = _scroll = 0;
_menu.active = false;
return true;
}
void show() { _active = true; } // back from picking a repeater: the list as it was
void close() {
if (_task) R().cancelTrace();
_active = false;
_menu.active = false;
}
int render(DisplayDriver& display) {
PathEditor& r = R();
r.poll();
const int n = r.count();
char title[32];
switch (r.state()) {
case PathEditor::RUNNING: snprintf(title, sizeof(title), "Tracing..."); break;
case PathEditor::DONE: snprintf(title, sizeof(title), "Path %lums", (unsigned long)r.rttMs()); break;
case PathEditor::TIMEOUT: snprintf(title, sizeof(title), "Path: no reply"); break;
case PathEditor::FAILED: snprintf(title, sizeof(title), "Path: no key"); break;
default: snprintf(title, sizeof(title), "Path: %s", r.targetName());
}
drawScreenHeader(display, title, -1, 0, true, false);
const bool done = r.state() == PathEditor::DONE;
drawList(display, rows(), _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {
drawRowSelection(display, y, sel, reserve);
char left[40], right[16] = "";
if (idx < n) {
snprintf(left, sizeof(left), "%d %s", idx + 1, r.hop(idx).name);
int16_t o, b;
if (done && r.hopSnrX4(idx, o) && r.hopSnrX4(2 * n - idx, b)) snprintf(right, sizeof(right), "%d/%d", (int)lroundf(o / 4.0f), (int)lroundf(b / 4.0f));
else if (r.hop(idx).known < 4) snprintf(right, sizeof(right), "?");
} else {
const int a = idx - n; // 0 Add, 1 Trace, 2 Save
if (a == 0) snprintf(left, sizeof(left), "+ Add repeater");
else if (a == 1) snprintf(left, sizeof(left), "%s", r.state() == PathEditor::RUNNING ? "Stop trace" : "Trace");
else if (a == 2) snprintf(left, sizeof(left), "Save path");
else snprintf(left, sizeof(left), "Keep path: %s", r.pinned() ? "ON" : "OFF");
}
const int rw = right[0] ? display.getTextWidth(right) + display.getCharWidth() : 0;
display.drawTextEllipsized(2, y, display.width() - reserve - 4 - rw, left, sel);
if (right[0]) {
display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT);
display.drawTextRightAlign(display.width() - reserve - 2, y, right);
}
});
if (_menu.active) _menu.render(display);
return r.state() == PathEditor::RUNNING ? 100 : 2000;
}
// Returns true: the key was used (the view is modal).
bool handleInput(char c) {
PathEditor& r = R();
if (_menu.active) {
auto res = _menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
int i = _menu.selectedIndex();
if (i == 0) { r.move(_menu_hop, -1); if (_sel > 0) _sel--; }
else if (i == 1) { r.move(_menu_hop, +1); if (_sel < r.count() - 1) _sel++; }
else r.remove(_menu_hop);
if (_sel >= rows()) _sel = rows() - 1;
_menu.active = false;
}
return true;
}
if (c == KEY_CANCEL) { close(); return true; }
if (c == KEY_UP) { _sel = _sel > 0 ? _sel - 1 : rows() - 1; return true; }
if (c == KEY_DOWN) { _sel = _sel < rows() - 1 ? _sel + 1 : 0; return true; }
const int n = r.count();
if (_sel < n && (keyIsPrev(c) || keyIsNext(c))) { // reorder in place
int dir = keyIsNext(c) ? 1 : -1;
int to = _sel + dir;
if (to >= 0 && to < n) { r.move(_sel, dir); _sel = to; }
return true;
}
if (c != KEY_ENTER && c != KEY_CONTEXT_MENU) return true;
if (_sel < n) { openHopMenu(_sel); return true; }
switch (_sel - n) {
case 0:
if (r.full()) _task->showAlert("Path is full", 1000);
else { r.cancelTrace(); _pick = true; _active = false; }
break;
case 1:
if (r.state() == PathEditor::RUNNING) r.cancelTrace();
else if (!r.trace() && r.state() != PathEditor::FAILED) _task->showAlert("Busy", 800);
break;
case 2:
if (r.count() == 0) _task->showAlert("Add a repeater", 1000);
else _task->showAlert(r.save() ? "Path saved" : "Can't save", 1000);
break;
default: // Keep: not replaced by a path heard, nor dropped after a failed send
r.setPinned(!r.pinned());
}
return true;
}
};
// ── Nearby Nodes ──────────────────────────────────────────────────────────────
// One list / detail / action-menu interaction path over two sources:
// SRC_STORED — contacts known to the mesh (distance / bearing / last-heard)
@@ -22,7 +156,7 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
// ── action-menu actions (matched by id, not by row index) ────────────────────
enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_LOCATOR,
ACT_ADD, ACT_DELETE, ACT_FAV, ACT_PIN, ACT_ADMIN, ACT_SORT, ACT_SCAN, ACT_MAP,
ACT_TELEM, ACT_RESET_PATH, ACT_SHARE };
ACT_TELEM, ACT_RESET_PATH, ACT_SHARE, ACT_ROUTE };
// Set by UITask::pickAdminTarget() (Tools > Admin, which is remote-only):
// while true, ENTER on an eligible row (a stored repeater/room contact) hands
@@ -33,6 +167,13 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
// startPickBotChannel()/startPickBotRoom() pick-mode idiom.
bool _pick_admin_target = false;
// Path view (RouteView above) and its repeater pick: Add hands the list over
// filtered to repeaters; Enter on one adds it, Back returns without.
RouteView _route;
bool _route_pick = false;
uint8_t _route_filter = F_ALL; // the filter to restore after the pick
uint8_t _route_key[PUB_KEY_SIZE];
int _sel;
int _scroll;
bool _detail;
@@ -374,6 +515,7 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
buildTelemLabel(contactctl::telemetry(*ci));
addValue(_telem_label, ACT_TELEM);
}
if (ci) add("Path / trace", ACT_ROUTE);
if (ci && contactctl::hasPath(*ci)) add("Reset path", ACT_RESET_PATH);
if (ci) add("Share nearby", ACT_SHARE);
if (is_admin_target) add("Admin", ACT_ADMIN);
@@ -457,6 +599,14 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
if (e && contactctl::resetPath(e->pub_key)) _task->showAlert("Path reset", 800);
break;
}
case ACT_ROUTE: {
const Entry* e = selected();
if (e && e->has_key && _route.open(_task, e->pub_key)) {
memcpy(_route_key, e->pub_key, PUB_KEY_SIZE);
_detail = true; // Back from the path lands on the node
}
break;
}
case ACT_SHARE: {
const Entry* e = selected();
_task->showAlert(e && contactctl::share(e->pub_key) ? "Shared nearby" : "No advert saved", 1000);
@@ -487,6 +637,25 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
}
}
// Add on the Path view: the list, repeaters only, until one is picked.
void startRoutePick() {
_route_pick = true;
_route_filter = _filter;
_filter = F_RPT;
_detail = false;
_sel = _scroll = 0;
refresh();
}
void endRoutePick() {
_route_pick = false;
_filter = _route_filter;
refresh();
int i = findContact(_route_key); // the node whose path it is, selected again
if (i >= 0) _sel = i;
_detail = true;
_route.show();
}
// ── detail rendering ──────────────────────────────────────────────────────────
void renderStoredDetail(DisplayDriver& display) {
const Entry& e = _entries[_sel];
@@ -762,6 +931,8 @@ public:
_confirm.active = false;
_pinging = false;
_pick_admin_target = false; // stale pick-mode from a previous visit shouldn't linger
_route.close();
_route_pick = false;
resetPingLines();
_task->clearPing();
refreshStored();
@@ -788,6 +959,9 @@ public:
_detail_refresh_ms = millis();
}
// ── path view (over the detail) ──────────────────────────────────────────
if (_route.active()) return _route.render(display);
// ── navigate-to-node view ────────────────────────────────────────────────
if (_nav && _sel < _count) {
const Entry& e = _entries[_sel];
@@ -940,6 +1114,13 @@ public:
}
bool handleInput(char c) override {
// ── path view: modal; Add hands the Nodes list over for one repeater ──────
if (_route.active()) {
_route.handleInput(c);
if (_route.takePick()) startRoutePick();
return true;
}
// ── navigate-to-node view — any nav key returns to detail ─────────────────
if (_nav) {
if (c == KEY_CANCEL) _nav = false; // only Back leaves a navigate view
@@ -998,16 +1179,25 @@ public:
// ── list view ───────────────────────────────────────────────────────────
if (c == KEY_CANCEL) {
if (_route_pick) { endRoutePick(); return true; }
if (_pick_admin_target) { _pick_admin_target = false; _task->leaveTool(); return true; }
if (_source == SRC_SCAN) leaveScan();
else _task->leaveTool();
return true;
}
if (c == KEY_CONTEXT_MENU) { openActionMenu(); return true; }
if (c == KEY_CONTEXT_MENU) { if (!_route_pick) openActionMenu(); return true; }
// drawList() reclamps _scroll from _sel every render.
if (c == KEY_UP && _count > 0) { _sel = (_sel > 0) ? _sel - 1 : _count - 1; return true; }
if (c == KEY_DOWN && _count > 0) { _sel = (_sel < _count - 1) ? _sel + 1 : 0; return true; }
if (c == KEY_ENTER) {
if (_route_pick) { // a repeater into the path
const Entry* e = selected();
if (e && e->has_key && e->type == ADV_TYPE_REPEATER) {
if (!_task->core().route.addRepeater(e->pub_key)) _task->showAlert("Already the last hop", 1000);
endRoutePick();
}
return true;
}
if (_pick_admin_target) {
const Entry* e = selected();
ContactInfo ci;
@@ -1024,6 +1214,7 @@ public:
_detail_refresh_ms = millis();
return true;
}
if (_route_pick) return true; // the filter stays on repeaters
if (keyIsPrev(c)) { tabbar::turn(_task->getDisplay(), filterLabels(), _filter, -1); _filter = (_filter + F_COUNT - 1) % F_COUNT; refresh(); return true; }
if (keyIsNext(c)) { tabbar::turn(_task->getDisplay(), filterLabels(), _filter, +1); _filter = (_filter + 1) % F_COUNT; refresh(); return true; }
return false;