feat(ui-lvgl): e-ink graphics in colour on the L2

Node detail: a card with a compass rose and the hop chain over the info;
the bar keeps Message / Admin / Add, Ping and Navigate, the rest goes to a
"More" group under the info (favourite toggled in place, delete by a second
tap). Nodes: an arrow by each distance. A trail's height profile in Map
tools and a saved trail's popup. Diagnostics > History: battery, noise,
SNR and traffic charts. Clock > Calendar: today, the weekend and alarm days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-02 23:26:29 +02:00
co-authored by Claude Opus 5.5
parent 99bd41650f
commit 9597e1e2a4
7 changed files with 582 additions and 37 deletions
+94 -6
View File
@@ -1,6 +1,7 @@
#pragma once
// Clock tools (Home > Clock): alarm, countdown timer and stopwatch -- ui-new's
// Tools > Clock on a touch screen. The alarm (NodePrefs alarm_*) and the
// Clock tools (Home > Clock): alarm, countdown timer, stopwatch and this
// month's calendar -- ui-new's Tools > Clock on a touch screen (the calendar
// as ui-new's e-ink has it under the clock). The alarm (NodePrefs alarm_*) and the
// countdown run in the Core's ClockEngine, so they fire on any screen and while
// the display sleeps; the stopwatch is view state here, as in ui-new. Whatever
// fires brings up a full-screen card with Dismiss (silent: the L2 speaker has
@@ -10,7 +11,7 @@
namespace clockview {
enum : uint8_t { TAB_ALARM, TAB_TIMER, TAB_STOPWATCH, TAB_COUNT };
enum : uint8_t { TAB_ALARM, TAB_TIMER, TAB_STOPWATCH, TAB_CALENDAR, TAB_COUNT };
enum : uint8_t { ACT_START_STOP, ACT_RESET };
static uint8_t s_tab = TAB_ALARM;
@@ -24,6 +25,63 @@ static lv_obj_t* s_ring = nullptr; // "Timer done" card on the top laye
static lv_obj_t* s_ring_lbl = nullptr;
static lv_obj_t* s_alarm_sw = nullptr; // follows a time change (setting it arms the alarm)
static struct tm s_cal_tm; // the day the calendar was drawn for (local)
static uint32_t s_cal_marks = 0; // its alarm days, bit (day - 1)
static lv_obj_t* s_cal = nullptr;
// The month as a grid, weeks from Monday: the weekday names, today in an
// accent box, the alarm's days with an accent bar under them, the weekend
// dimmer.
static void onCalendarDraw(lv_event_t* e) {
lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e);
lv_layer_t* layer = lv_event_get_layer(e);
lv_area_t a;
lv_obj_get_coords(o, &a);
const struct tm& t = s_cal_tm;
static const char* const NAMES[] = { "Mo", "Tu", "We", "Th", "Fr", "Sa", "Su" };
const int W = a.x2 - a.x1 + 1, cw = W / 7, x0 = a.x1 + (W - 7 * cw) / 2;
const int head = 16, rows = calmath::weeks(t), ch = (a.y2 - a.y1 + 1 - head) / rows;
lv_draw_label_dsc_t td;
lv_draw_label_dsc_init(&td);
td.align = LV_TEXT_ALIGN_CENTER;
td.font = THEME_FONT_SMALL;
for (int c = 0; c < 7; c++) {
td.color = lv_color_hex(theme::TEXT_MUTED);
td.text = NAMES[c];
lv_area_t la = { x0 + c * cw, a.y1, x0 + (c + 1) * cw - 1, a.y1 + head - 1 };
lv_draw_label(layer, &td, &la);
}
td.font = THEME_FONT_BODY;
const int fh = lv_font_get_line_height(td.font), col0 = calmath::firstCol(t), n = calmath::daysIn(t.tm_year, t.tm_mon);
char buf[3];
for (int day = 1; day <= n; day++) {
const int k = col0 + day - 1, cx = x0 + (k % 7) * cw, cy = a.y1 + head + (k / 7) * ch;
const bool today = day == t.tm_mday;
if (today) {
lv_draw_rect_dsc_t rd;
lv_draw_rect_dsc_init(&rd);
rd.radius = theme::RADIUS_SM;
rd.bg_color = lv_color_hex(theme::ACCENT);
const int bh = ch - 2 < fh + 2 ? ch - 2 : fh + 2, by = cy + (ch - bh) / 2;
lv_area_t ra = { cx + 4, by, cx + cw - 5, by + bh - 1 };
lv_draw_rect(layer, &rd, &ra);
}
snprintf(buf, sizeof(buf), "%d", day);
td.text = buf;
td.color = lv_color_hex(today ? theme::BG : k % 7 >= 5 ? theme::TEXT_MUTED : theme::TEXT);
lv_area_t la = { cx, cy + (ch - fh) / 2, cx + cw - 1, cy + (ch + fh) / 2 };
lv_draw_label(layer, &td, &la);
if (s_cal_marks & (1UL << (day - 1))) { // under the number
lv_draw_rect_dsc_t md;
lv_draw_rect_dsc_init(&md);
md.radius = 1;
md.bg_color = lv_color_hex(today ? theme::BG : theme::ACCENT);
lv_area_t ma = { cx + cw / 2 - 6, cy + ch - 2, cx + cw / 2 + 6, cy + ch - 1 };
lv_draw_rect(layer, &md, &ma);
}
}
}
static uint32_t swElapsed() { return s_sw_accum + (s_sw_running ? millis() - s_sw_start : 0); }
// Countdown: rounds up, so "00:01" shows until it actually fires.
@@ -243,12 +301,11 @@ void UITask::buildClock() {
lv_obj_t* body = newScreen("Clock", true);
lv_obj_set_style_pad_row(body, 6, 0);
lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE);
s_big = s_go_lbl = s_alarm_sw = nullptr;
s_big = s_go_lbl = s_alarm_sw = s_cal = nullptr;
// Tabs
lv_obj_t* tabs = row(body);
static const char* const NAMES[TAB_COUNT] = { LV_SYMBOL_BELL " Alarm", UI_SYMBOL_CLOCK " Timer",
UI_SYMBOL_STOPWATCH " Stopwatch" };
static const char* const NAMES[TAB_COUNT] = { "Alarm", "Timer", "Stopwatch", "Calendar" };
for (int t = 0; t < TAB_COUNT; t++) {
lv_obj_t* b = lv_button_create(tabs);
lv_obj_set_height(b, 32);
@@ -296,6 +353,29 @@ void UITask::buildClock() {
return;
}
if (s_tab == TAB_CALENDAR) {
struct tm t;
bool synced = localTime(_prefs, t);
if (!synced) {
label(body, "The clock isn't set yet: no date to show.", THEME_FONT_BODY, theme::TEXT_MUTED);
return;
}
static const char* const LONG_MONTHS[] = { "January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December" };
char title[24]; // the month on the header's right: the grid gets the height
snprintf(title, sizeof(title), "%s %d", LONG_MONTHS[t.tm_mon], t.tm_year + 1900);
if (_header) lv_obj_align(label(_header, title, THEME_FONT_TITLE, theme::TEXT_MUTED), LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
s_cal_tm = t;
s_cal_marks = calmath::alarmDays(t, _prefs->alarm_on, _prefs->alarm_repeat_mask, _prefs->alarm_hour, _prefs->alarm_min);
s_cal = lv_obj_create(body);
lv_obj_remove_style_all(s_cal);
lv_obj_remove_flag(s_cal, LV_OBJ_FLAG_CLICKABLE);
fillWidth(s_cal);
lv_obj_set_flex_grow(s_cal, 1);
lv_obj_add_event_cb(s_cal, onCalendarDraw, LV_EVENT_DRAW_MAIN_END, NULL);
return;
}
if (s_tab == TAB_TIMER) {
s_shown_running = _core->clock.isTimerRunning();
if (s_shown_running) {
@@ -343,6 +423,14 @@ void UITask::buildClock() {
// From loop() while SCR_CLOCK is shown: the running readouts.
void UITask::refreshClock() {
using namespace clockview;
if (s_tab == TAB_CALENDAR) { // past midnight, or the alarm changed: a new day to box or mark
struct tm t;
if (s_cal && localTime(_prefs, t)
&& (t.tm_mday != s_cal_tm.tm_mday || t.tm_mon != s_cal_tm.tm_mon
|| calmath::alarmDays(t, _prefs->alarm_on, _prefs->alarm_repeat_mask, _prefs->alarm_hour, _prefs->alarm_min) != s_cal_marks))
buildClock();
return;
}
if (s_tab == TAB_TIMER) {
bool running = _core->clock.isTimerRunning();
if (running != s_shown_running) { buildClock(); return; } // started elsewhere / just fired
+155 -5
View File
@@ -1,7 +1,9 @@
#pragma once
// Home > Diagnostics -- ui-new's Tools > Diagnostics. Tabs Live / System /
// Font / Noise; the rows come from ui-core/Diagnostics.h. Live refreshes every second
// and its header button resets the counters (after a confirm).
// Home > Diagnostics -- ui-new's Tools > Diagnostics. Tabs Live / History /
// System / Font / Noise; the rows come from ui-core/Diagnostics.h. Live
// refreshes every second and its header button resets the counters (after a
// confirm). History charts the last 8 hours (ui-core/StatusHistory.h, as on
// e-ink): the battery, the noise floor, the SNR of the last packets, traffic.
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp after DeviceScreen.h.
@@ -11,7 +13,7 @@ extern RADIO_CLASS radio; // the variant's target.cpp (the noise sweep retunes
namespace diagview {
enum : uint8_t { TAB_LIVE, TAB_SYSTEM, TAB_FONT, TAB_NOISE, TAB_COUNT };
enum : uint8_t { TAB_LIVE, TAB_HISTORY, TAB_SYSTEM, TAB_FONT, TAB_NOISE, TAB_COUNT };
static uint8_t s_tab = TAB_LIVE; // kept across visits
static lv_obj_t* s_list = nullptr;
static const int EXTRA = 4; // GPS, power, last reset, last crash (L2 only)
@@ -21,6 +23,118 @@ struct LiveVal { lv_obj_t* val; lv_obj_t* in; lv_obj_t* out; };
static LiveVal s_vals[diag::MAX_ROWS + EXTRA];
static int s_rows = 0;
// The history, a sample every 5 minutes from boot (UITask::sampleHistory()).
static uicore::StatusHistory<96, 300000UL> s_hist;
static uint32_t s_hist_shown = 0; // the history's version the charts show
static lv_obj_t* s_ch_obj[4]; // History's charts and their values now
static lv_obj_t* s_ch_now[4];
static uint32_t totalRecv() {
uint32_t n = 0;
for (uint8_t t = 0; t < 16; t++) n += the_mesh.getNumRecvByType(t);
return n;
}
// One chart's samples as drawn: a copy, so the draw callback needn't know
// which ring (of which size) they came from.
struct Chart { int16_t v[96]; int n, cap, min_span; bool bars; };
static Chart s_charts[4];
template <int N>
static Chart* chartOf(int k, const uicore::History<N>& h, int min_span, bool bars) {
Chart& c = s_charts[k];
c.n = h.n; c.cap = N; c.min_span = min_span; c.bars = bars;
for (int i = 0; i < h.n; i++) c.v[i] = h.at(i);
return &c;
}
// A history in its box, newest at the right: a line over a faint fill, or
// bars from the bottom, scaled between the samples' own min and max (at
// least min_span apart, so a steady reading stays flat).
static void onChartDraw(lv_event_t* e) {
lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e);
const Chart& c = *(const Chart*)lv_event_get_user_data(e);
lv_layer_t* layer = lv_event_get_layer(e);
lv_area_t a;
lv_obj_get_coords(o, &a);
const int x0 = a.x1 + 2, w = a.x2 - a.x1 - 4, y0 = a.y1 + 3, h = a.y2 - a.y1 - 5, base = a.y2 - 1;
lv_draw_line_dsc_t ld;
lv_draw_line_dsc_init(&ld);
if (c.n < (c.bars ? 1 : 2)) { // nothing yet: a dotted baseline
ld.color = lv_color_hex(theme::TEXT_MUTED);
ld.width = 1; ld.dash_width = 2; ld.dash_gap = 4;
ld.p1.x = x0; ld.p2.x = x0 + w; ld.p1.y = ld.p2.y = base - 1;
lv_draw_line(layer, &ld);
return;
}
int lo = c.bars ? 0 : c.v[0], hi = c.v[0];
for (int i = 0; i < c.n; i++) { if (c.v[i] < lo) lo = c.v[i]; if (c.v[i] > hi) hi = c.v[i]; }
if (hi - lo < c.min_span) {
if (c.bars) hi = lo + c.min_span;
else { const int mid = (hi + lo) / 2; lo = mid - c.min_span / 2; hi = lo + c.min_span; }
}
auto X = [&](int i) { return (int32_t)(x0 + w - (c.n - 1 - i) * w / (c.cap - 1)); };
auto Y = [&](int v) { return (int32_t)(y0 + h - (v - lo) * h / (hi - lo)); };
ld.color = lv_color_hex(theme::ACCENT);
if (c.bars) { // a bar a sample, a pixel apart
const int pitch = (w + c.cap / 2) / c.cap, bw = pitch > 2 ? pitch - 1 : 1;
lv_draw_rect_dsc_t rd;
lv_draw_rect_dsc_init(&rd);
rd.bg_color = lv_color_hex(theme::ACCENT);
rd.bg_opa = LV_OPA_70;
for (int i = 0; i < c.n; i++) {
if (c.v[i] <= 0) continue;
lv_area_t r = { X(i) - bw + 1, Y(c.v[i]), X(i), base };
lv_draw_rect(layer, &rd, &r);
}
return;
}
lv_draw_line_dsc_t fill;
lv_draw_line_dsc_init(&fill);
fill.color = lv_color_hex(theme::ACCENT);
fill.opa = LV_OPA_20;
fill.width = 1;
ld.width = 2;
ld.round_start = ld.round_end = 1;
for (int i = 1; i < c.n; i++) {
const int32_t xa = X(i - 1), xb = X(i), ya = Y(c.v[i - 1]), yb = Y(c.v[i]);
for (int32_t x = (i == 1 ? xa : xa + 1); x <= xb; x++) {
fill.p1.x = fill.p2.x = x;
fill.p1.y = xb > xa ? ya + (yb - ya) * (x - xa) / (xb - xa) : yb;
fill.p2.y = base;
lv_draw_line(layer, &fill);
}
ld.p1.x = xa; ld.p1.y = ya; ld.p2.x = xb; ld.p2.y = yb;
lv_draw_line(layer, &ld);
}
}
// A chart card: the title and the value now, the chart, the span under it.
static void chartCard(lv_obj_t* parent, int k, const char* title, const char* from) {
lv_obj_t* card = infoCard(parent);
lv_obj_set_style_pad_ver(card, 6, 0);
lv_obj_set_style_pad_row(card, 2, 0);
lv_obj_t* r = lv_obj_create(card);
lv_obj_remove_style_all(r);
fillWidth(r);
lv_obj_set_height(r, LV_SIZE_CONTENT);
lv_obj_align(label(r, title, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_LEFT_MID, 0, 0);
s_ch_now[k] = label(r, "", THEME_FONT_BODY, theme::TEXT);
lv_obj_align(s_ch_now[k], LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_t* ch = lv_obj_create(card);
lv_obj_remove_style_all(ch);
lv_obj_remove_flag(ch, LV_OBJ_FLAG_CLICKABLE);
fillWidth(ch);
lv_obj_set_height(ch, 48);
lv_obj_add_event_cb(ch, onChartDraw, LV_EVENT_DRAW_MAIN_END, &s_charts[k]);
s_ch_obj[k] = ch;
lv_obj_t* ax = lv_obj_create(card);
lv_obj_remove_style_all(ax);
fillWidth(ax);
lv_obj_set_height(ax, LV_SIZE_CONTENT);
lv_obj_align(label(ax, from, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_LEFT_MID, 0, 0);
lv_obj_align(label(ax, "now", THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_RIGHT_MID, 0, 0);
}
static void extraRow(diag::Row* rows, int& n, const char* lbl, const char* fmt, ...) {
rows[n].label = lbl;
va_list ap; va_start(ap, fmt);
@@ -251,7 +365,7 @@ void UITask::buildDiag() {
lv_obj_set_style_pad_row(body, 4, 0);
if (s_tab == TAB_LIVE && _header) headerButton(_header, LV_SYMBOL_REFRESH " Reset", onDiagReset, 4, NULL);
static const char* TABS[] = { "Live", "System", "Font", "Noise", "" };
static const char* TABS[] = { "Live", "History", "System", "Font", "Noise", "" };
lv_obj_t* tabs = segmented(body, TABS, s_tab, lv_pct(100), 34);
lv_obj_set_style_bg_color(tabs, lv_color_hex(theme::SURFACE), LV_PART_ITEMS);
lv_obj_add_event_cb(tabs, onDiagTab, LV_EVENT_VALUE_CHANGED, NULL);
@@ -270,6 +384,14 @@ void UITask::buildDiag() {
}
s_noise_status = nullptr;
if (s_tab == TAB_HISTORY) {
chartCard(s_list, 0, "Battery", "8 h ago");
chartCard(s_list, 1, "Noise floor", "8 h ago");
chartCard(s_list, 2, "SNR, last packets", "32 packets ago");
chartCard(s_list, 3, "Packets heard, per 5 min", "8 h ago");
fillHistory();
return;
}
if (s_tab == TAB_NOISE) {
noteLabel(s_list, "What the radio hears with nothing on air, on the mesh frequency and 1.1 MHz "
"either side. Takes about 12 seconds.");
@@ -365,8 +487,35 @@ void UITask::diagSection(int sec) {
}
}
// History's charts and values from the history as it is now.
void UITask::fillHistory() {
using namespace diagview;
s_hist_shown = s_hist.version;
chartOf(0, s_hist.batt, 20, false);
chartOf(1, s_hist.noise, 6, false);
chartOf(2, s_hist.snr, 20, false);
chartOf(3, s_hist.traffic, 4, true);
for (int k = 0; k < 4; k++) lv_obj_invalidate(s_ch_obj[k]);
setTextFmt(s_ch_now[0], "%.2f V", (_batt_mv ? _batt_mv : getBattMilliVolts()) / 1000.0f);
const int nf = (int)radio_driver.getNoiseFloor();
if (nf) setTextFmt(s_ch_now[1], "%d dBm", nf); else setText(s_ch_now[1], "-");
if (s_hist.snr.n) setTextFmt(s_ch_now[2], "%.1f dB", s_hist.snr.at(s_hist.snr.n - 1) / 4.0f); else setText(s_ch_now[2], "-");
setTextFmt(s_ch_now[3], "%d", s_hist.traffic.n ? s_hist.traffic.at(s_hist.traffic.n - 1) : 0);
}
// From loop(): the history's readings (it keeps its own pace).
void UITask::sampleHistory() {
using namespace diagview;
s_hist.tick(millis(), (int)radio_driver.getNoiseFloor(), _batt_mv ? _batt_mv : getBattMilliVolts(),
totalRecv(), radio_driver.getLastSNR());
}
void UITask::refreshDiag() {
using namespace diagview;
if (_screen == SCR_DIAG && s_tab == TAB_HISTORY && !_nav_overlay && s_hist.version != s_hist_shown) {
fillHistory(); // a new sample: the charts in place, the list stays where it was scrolled
return;
}
if (_screen != SCR_DIAG || s_tab != TAB_LIVE || _nav_overlay) return;
diag::Row rows[diag::MAX_ROWS + EXTRA];
int n = allRows(rows, _core->gpsEnabled());
@@ -381,6 +530,7 @@ void UITask::diagResetPopup() {
void UITask::diagReset() {
diag::resetCounters();
diagview::s_hist.countersReset();
navClosePopup();
refreshDiag();
}
+10 -3
View File
@@ -899,7 +899,7 @@ static void trailTitle(const char* name, char* out, size_t n) {
// Points, distance and recorded time of a saved trail, from its file.
template <typename F>
static bool trailSummary(F& io, int& points, float& meters, uint32_t& secs) {
static bool trailSummary(F& io, int& points, float& meters, uint32_t& secs, trailprofile::Sampler* prof = nullptr) {
uint16_t cnt = 0;
uint32_t accum = 0;
if (!persist::readHeader(io, TrailStore::SAVE_MAGIC, TrailStore::SAVE_VERSION, cnt, 1)) return false;
@@ -910,6 +910,7 @@ static bool trailSummary(F& io, int& points, float& meters, uint32_t& secs) {
if (io.read((uint8_t*)&p, sizeof(p)) != (int)sizeof(p)) break;
if (points > 0 && !(p.flags & TRAIL_FLAG_SEG_START))
meters += TrailStore::haversineMeters(prev.lat_1e6, prev.lon_1e6, p.lat_1e6, p.lon_1e6);
if (prof) prof->feed(p);
prev = p;
points++;
}
@@ -963,6 +964,9 @@ void UITask::navToolsPopup() {
sectionTitle(list, "TRAIL");
_nav_trail_lbl = label(list, "", THEME_FONT_SMALL, theme::TEXT);
profileview::s_prof = trailprofile::Sampler(); // its height profile, as it is now
profileview::s_prof.feedAll(_core->trail.store());
profileChart(list, 72);
lv_obj_t* r = buttonBar(list);
_nav_trail_btn = toolButton(r, "", navmap::TL_TRAIL_TOGGLE, true);
toolButton(r, LV_SYMBOL_SAVE " Save", navmap::TL_TRAIL_SAVE, false);
@@ -1220,13 +1224,15 @@ void UITask::savedTrailPopup(int idx) {
lv_obj_t* panel = navPopupPanel(title, false);
int pts = 0; float m = 0; uint32_t secs = 0; bool ok = false;
trailprofile::Sampler& prof = profileview::s_prof;
prof = trailprofile::Sampler();
if (idx >= 0) {
char path[64];
snprintf(path, sizeof(path), "%s/%s", navmap::TRAILS_DIR, navmap::s_st_names[idx]);
if (FILE* f = fopen(path, "rb")) { navmap::FileRW io{ f }; ok = navmap::trailSummary(io, pts, m, secs); fclose(f); }
if (FILE* f = fopen(path, "rb")) { navmap::FileRW io{ f }; ok = navmap::trailSummary(io, pts, m, secs, &prof); fclose(f); }
} else if (DataStore* ds = the_mesh.getDataStore()) {
File f = ds->openRead(TrailEngine::TRAIL_FILE);
if (f) { ok = navmap::trailSummary(f, pts, m, secs); f.close(); }
if (f) { ok = navmap::trailSummary(f, pts, m, secs, &prof); f.close(); }
}
char dist[12], dur[12], info[64];
geo::fmtDist(dist, sizeof(dist), m / 1000.0f, _prefs && _prefs->units_imperial);
@@ -1234,6 +1240,7 @@ void UITask::savedTrailPopup(int idx) {
if (ok) snprintf(info, sizeof(info), "%s - %s - %d points", dist, dur, pts);
else snprintf(info, sizeof(info), "Can't read this file");
label(panel, info, THEME_FONT_BODY, theme::TEXT);
if (ok) profileChart(panel, 60);
if (!_core->trail.store().empty())
label(panel, "Loading replaces the trail on the map.", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_t* r = buttonBar(panel);
+170
View File
@@ -0,0 +1,170 @@
#pragma once
// Node detail's picture card, over its info rows: where the node is, on a
// small compass rose (north up, an arrow towards it), and how a message gets
// there, a chain from you to it with a ring per repeater on the way. Either half shows only when
// there's something to draw (a position of both ends / a contact's path).
// ui-new draws the same on e-ink (NearbyScreen.h).
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp before the node detail.
namespace nodeviz {
static const int ROSE_W = 64, ROSE_H = 76; // the ring, N over it
static lv_obj_t* s_card = nullptr;
static lv_obj_t* s_rose = nullptr;
static lv_obj_t* s_path_col = nullptr;
static lv_obj_t* s_chain = nullptr;
static lv_obj_t* s_path_txt = nullptr;
static int s_bearing = -1; // degrees, -1: no position
static int s_hops = -1; // 0..63, 0xFF flood, -1: not a contact
static void onRoseDraw(lv_event_t* e) {
lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e);
lv_layer_t* layer = lv_event_get_layer(e);
lv_area_t a;
lv_obj_get_coords(o, &a);
const float r = ROSE_W / 2.0f - 8, cx = (a.x1 + a.x2) / 2.0f, cy = a.y2 - r - 3;
lv_draw_arc_dsc_t ad; // the ring
lv_draw_arc_dsc_init(&ad);
ad.center.x = (int32_t)cx; ad.center.y = (int32_t)cy;
ad.radius = (uint16_t)r;
ad.start_angle = 0; ad.end_angle = 360;
ad.width = 1;
ad.color = lv_color_hex(theme::TEXT_MUTED);
lv_draw_arc(layer, &ad);
lv_draw_line_dsc_t ld; // a tick every 45 degrees, longer at the cardinals
lv_draw_line_dsc_init(&ld);
ld.color = lv_color_hex(theme::TEXT_MUTED);
for (int deg = 0; deg < 360; deg += 45) {
const float ang = deg * (float)M_PI / 180.0f, sx = sinf(ang), sy = -cosf(ang);
const float len = deg % 90 == 0 ? 6 : 3;
ld.width = deg % 90 == 0 ? 2 : 1;
ld.p1.x = cx + sx * r; ld.p1.y = cy + sy * r;
ld.p2.x = cx + sx * (r - len); ld.p2.y = cy + sy * (r - len);
lv_draw_line(layer, &ld);
}
lv_draw_label_dsc_t td; // N above the ring
lv_draw_label_dsc_init(&td);
td.align = LV_TEXT_ALIGN_CENTER;
td.font = THEME_FONT_SMALL;
td.color = lv_color_hex(theme::FAIL);
td.text = "N";
const int32_t h = lv_font_get_line_height(td.font);
lv_area_t la = { (int32_t)cx - 10, (int32_t)(cy - r) - h - 1, (int32_t)cx + 10, (int32_t)(cy - r) - 1 };
lv_draw_label(layer, &td, &la);
if (s_bearing < 0) return;
const float ang = s_bearing * (float)M_PI / 180.0f, sx = sinf(ang), sy = -cosf(ang);
const float tip = r - 4, base = tip - 12;
ld.color = lv_color_hex(theme::ACCENT); // the arrow: a shaft from the centre, a head at the ring
ld.width = 3;
ld.round_start = ld.round_end = 1;
ld.p1.x = cx - sx * (r * 0.35f); ld.p1.y = cy - sy * (r * 0.35f);
ld.p2.x = cx + sx * base; ld.p2.y = cy + sy * base;
lv_draw_line(layer, &ld);
lv_draw_triangle_dsc_t tri;
lv_draw_triangle_dsc_init(&tri);
tri.color = lv_color_hex(theme::ACCENT);
tri.opa = LV_OPA_COVER;
tri.p[0].x = cx + sx * tip; tri.p[0].y = cy + sy * tip;
tri.p[1].x = cx + sx * base + sy * 7; tri.p[1].y = cy + sy * base - sx * 7;
tri.p[2].x = cx + sx * base - sy * 7; tri.p[2].y = cy + sy * base + sx * 7;
lv_draw_triangle(layer, &tri);
}
// You and the node filled in the accent, the repeaters between as rings,
// dotted links; as many rings as fit, the count says the rest.
static void onChainDraw(lv_event_t* e) {
if (s_hops < 0 || s_hops == 0xFF) return;
lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e);
lv_layer_t* layer = lv_event_get_layer(e);
lv_area_t a;
lv_obj_get_coords(o, &a);
const int R = 6, PITCH = 26, w = a.x2 - a.x1 + 1, cy = (a.y1 + a.y2) / 2;
const int cap = (w - 2 * R) / PITCH - 1;
const int shown = s_hops > cap ? (cap < 1 ? 1 : cap) : s_hops, n = shown + 2;
lv_draw_line_dsc_t ld;
lv_draw_line_dsc_init(&ld);
ld.color = lv_color_hex(theme::TEXT_MUTED);
ld.width = 2;
ld.dash_width = 3; ld.dash_gap = 3;
ld.p1.x = a.x1 + R; ld.p1.y = cy;
ld.p2.x = a.x1 + R + (n - 1) * PITCH; ld.p2.y = cy;
lv_draw_line(layer, &ld);
for (int k = 0; k < n; k++) {
const bool end = k == 0 || k == n - 1;
lv_draw_rect_dsc_t rd;
lv_draw_rect_dsc_init(&rd);
rd.radius = LV_RADIUS_CIRCLE;
rd.bg_color = lv_color_hex(end ? theme::ACCENT : theme::SURFACE);
rd.border_color = lv_color_hex(end ? theme::ACCENT : theme::TEXT_MUTED);
rd.border_width = 2;
const int x = a.x1 + R + k * PITCH;
lv_area_t ra = { x - R, cy - R, x + R, cy + R };
lv_draw_rect(layer, &rd, &ra);
}
}
} // namespace nodeviz
// The card, over the node's info rows. Filled by nodeVizSet().
static void nodeVizBuild(lv_obj_t* parent) {
using namespace nodeviz;
s_card = lv_obj_create(parent);
styleSurface(s_card, theme::SURFACE);
lv_obj_remove_flag(s_card, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_remove_flag(s_card, LV_OBJ_FLAG_CLICKABLE);
fillWidth(s_card);
lv_obj_set_height(s_card, LV_SIZE_CONTENT);
lv_obj_set_style_radius(s_card, theme::RADIUS, 0);
lv_obj_set_style_pad_all(s_card, 6, 0);
lv_obj_set_flex_flow(s_card, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(s_card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(s_card, 12, 0);
s_rose = lv_obj_create(s_card);
lv_obj_remove_style_all(s_rose);
lv_obj_remove_flag(s_rose, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(s_rose, ROSE_W, ROSE_H);
lv_obj_add_event_cb(s_rose, onRoseDraw, LV_EVENT_DRAW_MAIN_END, NULL);
s_path_col = lv_obj_create(s_card);
lv_obj_remove_style_all(s_path_col);
lv_obj_remove_flag(s_path_col, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_width(s_path_col, 1);
lv_obj_set_flex_grow(s_path_col, 1);
lv_obj_set_height(s_path_col, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(s_path_col, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(s_path_col, 4, 0);
label(s_path_col, "Path", THEME_FONT_SMALL, theme::TEXT_MUTED);
s_chain = lv_obj_create(s_path_col);
lv_obj_remove_style_all(s_chain);
lv_obj_remove_flag(s_chain, LV_OBJ_FLAG_CLICKABLE);
fillWidth(s_chain);
lv_obj_set_height(s_chain, 16);
lv_obj_add_event_cb(s_chain, onChainDraw, LV_EVENT_DRAW_MAIN_END, NULL);
s_path_txt = label(s_path_col, "", THEME_FONT_BODY, theme::TEXT);
}
// `bearing` in degrees or -1; `hops` 0..63, 0xFF (flood) or -1 (no contact).
static void nodeVizSet(int bearing, int hops) {
using namespace nodeviz;
if (!s_card) return;
if (bearing != s_bearing) { s_bearing = bearing; lv_obj_invalidate(s_rose); }
if (hops != s_hops) {
s_hops = hops;
lv_obj_invalidate(s_chain);
char t[24];
if (hops == 0xFF) snprintf(t, sizeof(t), "Flood, no route yet");
else if (hops == 0) snprintf(t, sizeof(t), "Direct");
else snprintf(t, sizeof(t), "%d hop%s", hops, hops == 1 ? "" : "s");
setText(s_path_txt, t);
if (hops == 0xFF) lv_obj_add_flag(s_chain, LV_OBJ_FLAG_HIDDEN); else lv_obj_remove_flag(s_chain, LV_OBJ_FLAG_HIDDEN);
}
if (bearing < 0) lv_obj_add_flag(s_rose, LV_OBJ_FLAG_HIDDEN); else lv_obj_remove_flag(s_rose, LV_OBJ_FLAG_HIDDEN);
if (hops < 0) lv_obj_add_flag(s_path_col, LV_OBJ_FLAG_HIDDEN); else lv_obj_remove_flag(s_path_col, LV_OBJ_FLAG_HIDDEN);
if (bearing < 0 && hops < 0) lv_obj_add_flag(s_card, LV_OBJ_FLAG_HIDDEN); else lv_obj_remove_flag(s_card, LV_OBJ_FLAG_HIDDEN);
}
@@ -0,0 +1,70 @@
#pragma once
// A trail's height profile (ui-core/TrailProfile.h) as a chart: the height
// over the distance, a line in the accent over a faint fill, the climb and
// the highest and lowest points over it, top left. In the live trail's Map tools section and
// a saved trail's popup. ui-new draws the same on e-ink and the OLED
// (TrailScreen.h).
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp before NavMap.h.
namespace profileview {
static trailprofile::Sampler s_prof; // what the chart on screen shows
static void onDraw(lv_event_t* e) {
const trailprofile::Sampler& p = s_prof;
if (p.n < 2) return;
lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e);
lv_layer_t* layer = lv_event_get_layer(e);
lv_area_t a;
lv_obj_get_coords(o, &a);
int lo = p.st.lo, hi = p.st.hi;
if (hi - lo < 20) { const int mid = (hi + lo) / 2; lo = mid - 10; hi = mid + 10; }
const float total = p.total > 0 ? p.total : 1.0f;
const int x0 = a.x1 + 2, w = a.x2 - a.x1 - 4, y0 = a.y1 + 20, h = a.y2 - y0 - 2, base = a.y2 - 1; // under the caption
auto X = [&](int i) { return (int32_t)(x0 + p.dist[i] / total * w); };
auto Y = [&](int i) { return (int32_t)(y0 + h - (p.alt[i] - lo) * h / (hi - lo)); };
lv_draw_line_dsc_t fill; // under the line, a column at a time (triangles would leave seams)
lv_draw_line_dsc_init(&fill);
fill.color = lv_color_hex(theme::ACCENT);
fill.opa = LV_OPA_20;
fill.width = 1;
lv_draw_line_dsc_t ld;
lv_draw_line_dsc_init(&ld);
ld.color = lv_color_hex(theme::ACCENT);
ld.width = 2;
ld.round_start = ld.round_end = 1;
for (int i = 1; i < p.n; i++) {
const int32_t xa = X(i - 1), xb = X(i), ya = Y(i - 1), yb = Y(i);
for (int32_t x = (i == 1 ? xa : xa + 1); x <= xb; x++) {
fill.p1.x = fill.p2.x = x;
fill.p1.y = xb > xa ? ya + (yb - ya) * (x - xa) / (xb - xa) : yb;
fill.p2.y = base;
lv_draw_line(layer, &fill);
}
ld.p1.x = xa; ld.p1.y = ya; ld.p2.x = xb; ld.p2.y = yb;
lv_draw_line(layer, &ld);
}
}
} // namespace profileview
// The chart, `h` tall, into `parent`, for what s_prof holds; nothing when
// the trail has no heights. Returns the chart, or null.
static lv_obj_t* profileChart(lv_obj_t* parent, int h) {
using namespace profileview;
if (!s_prof.st.any || s_prof.n < 2) return nullptr;
lv_obj_t* c = lv_obj_create(parent);
styleSurface(c, theme::SURFACE);
lv_obj_remove_flag(c, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_remove_flag(c, LV_OBJ_FLAG_CLICKABLE);
fillWidth(c);
lv_obj_set_height(c, h);
lv_obj_set_style_radius(c, theme::RADIUS_SM, 0);
lv_obj_add_event_cb(c, onDraw, LV_EVENT_DRAW_MAIN_END, NULL);
char t[64];
snprintf(t, sizeof(t), "Climb %d m - highest %d m - lowest %d m", s_prof.st.gain, s_prof.st.hi, s_prof.st.lo);
lv_obj_align(label(c, t, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_TOP_LEFT, 6, 2);
return c;
}
+81 -23
View File
@@ -74,6 +74,9 @@ static bool s_chats_stale = false; // Messages: a refresh waits for a held row
#include "../ui-core/SettingsSchema.h"
#include "../ui-core/GpsAverager.h"
#include "../ui-core/TrackBack.h"
#include "../ui-core/TrailProfile.h"
#include "../ui-core/StatusHistory.h"
#include "../ui-core/CalendarMath.h"
#include "../ui-core/RadioControl.h"
#include "../ui-core/RepeaterControl.h"
#include "../ui-core/Diagnostics.h"
@@ -1620,6 +1623,7 @@ extern "C" lv_result_t __wrap_lv_obj_send_event(lv_obj_t* o, lv_event_code_t c,
void UITask::loop() {
pollConnection();
drainCoreEvents();
sampleHistory();
// USER (BOOT, side) button: Home's clock page; held and let go, mutes / unmutes, also with the
// screen off, which it doesn't wake. WAKE (top) button: screen off / on.
@@ -2607,6 +2611,8 @@ int UITask::fillPending() const {
return n;
}
#include "NodeViz.h" // the node detail's rose and path (newScreen() drops its card)
lv_obj_t* UITask::newScreen(const char* title, bool with_back) {
s_fill_n = 0; // what was still to be built goes with the screen
_home_clock = _home_date = _home_unread = nullptr;
@@ -2617,6 +2623,7 @@ lv_obj_t* UITask::newScreen(const char* title, bool with_back) {
_header = _body = nullptr;
_nearby_list = _nearby_status = _nearby_sort_lbl = _nearby_chips = nullptr;
_node_info = _node_ping = _node_delete_lbl = nullptr;
nodeviz::s_card = nullptr;
_scan_overlay = _scan_list = _scan_status = nullptr; // the popup went with the old screen
_map_area = _map_marks = _map_me = _map_zoom_lbl = _map_hint = _map_dl_pill = _map_center_btn = nullptr;
_dl_overlay = _dl_info = _dl_sub = _dl_start_lbl = _dl_job_row = _dl_job_lbl = nullptr;
@@ -3602,6 +3609,27 @@ static void nearbyText(const NearbyModel::Entry& e, uint32_t now, bool imperial,
t.right_col = e.is_live ? theme::OK : theme::TEXT_MUTED;
}
// A small arrow towards the node, drawn in the value label's left padding:
// its user data is the bearing + 1 (0: none).
static const int ROW_ARROW_W = 14;
static void onRowArrowDraw(lv_event_t* e) {
lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e);
const int b = (int)(intptr_t)lv_obj_get_user_data(o) - 1;
if (b < 0) return;
lv_area_t a;
lv_obj_get_coords(o, &a);
const float cx = a.x1 + ROW_ARROW_W / 2 - 1, cy = (a.y1 + a.y2) / 2.0f;
const float ang = b * (float)M_PI / 180.0f, sx = sinf(ang), sy = -cosf(ang);
lv_draw_triangle_dsc_t tri;
lv_draw_triangle_dsc_init(&tri);
tri.color = lv_obj_get_style_text_color(o, LV_PART_MAIN);
tri.opa = LV_OPA_COVER;
tri.p[0].x = cx + sx * 6; tri.p[0].y = cy + sy * 6; // the tip
tri.p[1].x = cx - sx * 5 + sy * 4; tri.p[1].y = cy - sy * 5 - sx * 4;
tri.p[2].x = cx - sx * 5 - sy * 4; tri.p[2].y = cy - sy * 5 + sx * 4;
lv_draw_triangle(lv_event_get_layer(e), &tri);
}
void UITask::refreshNearbyList() {
if (!_nearby_list) return;
_nearby->refreshModel();
@@ -3621,13 +3649,27 @@ void UITask::refreshNearbyList() {
// order); the list stays where it was scrolled.
static const vlist::Kind ROW = {
theme::ROW_H,
[](lv_obj_t* list) { return vRowValue(list, onNearbyRow); },
[](lv_obj_t* list) {
lv_obj_t* row = vRowValue(list, onNearbyRow);
lv_obj_add_event_cb(lv_obj_get_child(row, 2), onRowArrowDraw, LV_EVENT_DRAW_MAIN_END, NULL);
return row;
},
[](lv_obj_t* row, intptr_t i) {
const NearbyModel::Entry& e = s_ui->_nearby->at((int)i);
NearbyText t;
nearbyText(e, rtc_clock.getCurrentTime(), s_ui->_prefs && s_ui->_prefs->units_imperial, t);
rowSet(row, t.title, t.sub, e.fav ? theme::ACCENT : theme::TEXT);
rowValue(row, t.right, t.right_col);
// By the distance, an arrow towards it
int b = -1;
int32_t lat, lon;
if (e.dist_km >= 0.0f && s_ui->_nearby->ownPosition(lat, lon)) b = geo::bearingDeg(lat, lon, e.lat_e6, e.lon_e6);
lv_obj_t* v = lv_obj_get_child(row, 2);
if ((intptr_t)lv_obj_get_user_data(v) != b + 1) {
lv_obj_set_user_data(v, (void*)(intptr_t)(b + 1));
lv_obj_set_style_pad_left(v, b >= 0 ? ROW_ARROW_W : 0, 0);
lv_obj_invalidate(v);
}
},
};
vlist::begin(_nearby_list);
@@ -3653,11 +3695,19 @@ void UITask::openNode(int row) {
buildNode();
}
static lv_obj_t* s_nd_fav = nullptr;
static void nodeFavText() {
if (s_nd_fav) lv_label_set_text(s_nd_fav, s_node.fav ? UI_SYMBOL_STAR " Remove from favourites" : UI_SYMBOL_STAR " Add to favourites");
}
void UITask::buildNode() {
const NearbyModel::Entry& e = s_node;
lv_obj_t* body = newScreen(e.name[0] ? e.name : "(unknown)", true);
lv_obj_t* info = scrollList(body); // the info, scrolling above the actions at the bottom
s_nd_fav = nullptr;
nodeVizBuild(info);
nodeviz::s_bearing = nodeviz::s_hops = -2; // nothing drawn yet: the first set fills both
_node_info = infoCard(info);
s_nd_type = infoRow(_node_info, "Type", "");
s_nd_status = infoRow(_node_info, "Status", "");
@@ -3668,26 +3718,26 @@ void UITask::buildNode() {
s_nd_id = infoRow(_node_info, "ID", "");
_node_ping = label(info, "", THEME_FONT_BODY, theme::ACCENT);
// The bar: what the node is for -- a message, its admin, adding it -- a
// ping and the way there. The rest, less often, in a group under the info.
lv_obj_t* acts = buttonBar(body);
bool contact = e.contact_idx >= 0;
bool admin = contact && (e.type == ADV_TYPE_REPEATER || e.type == ADV_TYPE_ROOM);
bool pos = e.lat_e6 != 0 || e.lon_e6 != 0;
struct Act { const char* icon; const char* text; uint8_t action; bool accent; } list[9];
int n = 0;
if (contact && e.type == ADV_TYPE_CHAT) list[n++] = { LV_SYMBOL_ENVELOPE, " Message", NODE_MSG, true };
if (e.has_key) list[n++] = { LV_SYMBOL_LOOP, " Ping", NODE_PING, false };
if (pos) list[n++] = { UI_SYMBOL_COMPASS, "", NODE_NAV, false };
if (pos) list[n++] = { UI_SYMBOL_FLAG, "", NODE_WAYPOINT, false }; // save where it was seen
if (contact) list[n++] = { UI_SYMBOL_STAR, admin ? "" : e.fav ? " Unfav" : " Fav", NODE_FAV, e.fav }; // lit while it is one
if (admin) list[n++] = { LV_SYMBOL_SETTINGS, " Admin", NODE_ADMIN, false };
if (!contact && e.has_key && !e.is_known) list[n++] = { LV_SYMBOL_PLUS, " Add", NODE_ADD, true };
if (contact && e.has_key) list[n++] = { UI_SYMBOL_TACK, "", NODE_PIN, favslots::findContact(_prefs, e.pub_key) >= 0 };
if (contact) list[n++] = { LV_SYMBOL_TRASH, "", NODE_DELETE, false };
for (int i = 0; i < n; i++) { // five or more: icons only, so every button fits one row
char t[24];
snprintf(t, sizeof(t), "%s%s", list[i].icon, n >= 5 ? "" : list[i].text);
lv_obj_t* b = barButton(acts, t, onNodeAction, list[i].action, list[i].accent);
if (list[i].action == NODE_DELETE) _node_delete_lbl = lv_obj_get_child(b, 0);
if (contact && e.type == ADV_TYPE_CHAT) barButton(acts, LV_SYMBOL_ENVELOPE " Message", onNodeAction, NODE_MSG, true);
if (admin) barButton(acts, LV_SYMBOL_SETTINGS " Admin", onNodeAction, NODE_ADMIN, true);
if (!contact && e.has_key && !e.is_known) barButton(acts, LV_SYMBOL_PLUS " Add", onNodeAction, NODE_ADD, true);
if (e.has_key) barButton(acts, LV_SYMBOL_LOOP " Ping", onNodeAction, NODE_PING);
if (pos) barButton(acts, UI_SYMBOL_COMPASS " Navigate", onNodeAction, NODE_NAV);
if (pos || contact) {
lv_obj_t* g = group(info, "More");
if (pos) actionRow(g, UI_SYMBOL_FLAG " Save as waypoint", onNodeAction, (void*)(uintptr_t)NODE_WAYPOINT);
if (contact) s_nd_fav = actionRow(g, "", onNodeAction, (void*)(uintptr_t)NODE_FAV);
nodeFavText();
if (contact && e.has_key) actionRow(g, UI_SYMBOL_TACK " Pin to Home", onNodeAction, (void*)(uintptr_t)NODE_PIN);
if (contact) _node_delete_lbl = actionRow(g, LV_SYMBOL_TRASH " Delete contact", onNodeAction,
(void*)(uintptr_t)NODE_DELETE, theme::FAIL);
}
refreshNode();
@@ -3717,11 +3767,13 @@ void UITask::refreshNode() {
if (e.fav) tag("favourite");
if (status[0] >= 'a' && status[0] <= 'z') status[0] -= 'a' - 'A';
int32_t lat, lon;
int bearing = -1;
if (e.lat_e6 != 0 || e.lon_e6 != 0) {
if (_nearby->ownPosition(lat, lon) && e.dist_km >= 0.0f) {
char d[16];
geo::fmtDist(d, sizeof(d), e.dist_km, _prefs && _prefs->units_imperial);
int az = geo::bearingDeg(lat, lon, e.lat_e6, e.lon_e6);
bearing = az;
snprintf(dist, sizeof(dist), "%s %d\xC2\xB0 %s", d, az, geo::bearingCardinal(az));
}
snprintf(pos, sizeof(pos), "%.5f, %.5f", e.lat_e6 / 1e6, e.lon_e6 / 1e6);
@@ -3741,6 +3793,11 @@ void UITask::refreshNode() {
infoSet(s_nd_heard, heard);
infoSet(s_nd_signal, sig);
infoSet(s_nd_id, id);
ContactInfo ci;
int hops = -1;
if (!_node_from_scan && e.contact_idx >= 0 && the_mesh.getContactByIdx(e.contact_idx, ci))
hops = ci.out_path_len == 0xFF ? 0xFF : (ci.out_path_len & 63);
nodeVizSet(bearing, hops);
// Ping result (PingEngine releases its slot on reply; the view owns the timeout)
if (_pinging) {
@@ -3779,9 +3836,10 @@ void UITask::nodeAction(uint8_t action) {
break;
}
case NODE_FAV:
if (contactctl::setFavourite(e.pub_key, !e.fav)) {
if (contactctl::setFavourite(e.pub_key, !e.fav)) { // in place: the list stays where it was scrolled
e.fav = !e.fav;
buildNode();
nodeFavText();
refreshNode();
}
break;
case NODE_ADD:
@@ -3819,10 +3877,7 @@ void UITask::nodeAction(uint8_t action) {
pinPopup(false, 0, e.pub_key);
break;
case NODE_DELETE:
if (!tapConfirmed(_node_delete_lbl, LV_SYMBOL_TRASH "?")) { // "?" fits an icon-only button
showToast("Tap again to delete the contact", 2500);
break;
}
if (!tapConfirmed(_node_delete_lbl, LV_SYMBOL_TRASH " Tap again to delete")) break;
if (the_mesh.deleteContactByKey(e.pub_key)) {
_screen = SCR_NEARBY;
buildNearby();
@@ -3833,6 +3888,7 @@ void UITask::nodeAction(uint8_t action) {
static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref); // below
#include "MapScreen.h"
#include "ProfileView.h"
#include "NavMap.h"
#include "MapAreas.h"
#include "MapRegions.h"
@@ -4924,6 +4980,8 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) {
if (!strcmp(name, "regions")) { s_ui->mapRegionsPopup(); return; }
if (!strcmp(name, "region0")) { s_ui->mapRegionPopup(0); return; }
if (!strcmp(name, "advert")) { s_ui->advertPopup(); return; }
if (!strcmp(name, "trailsave")) { s_ui->navToolAction(navmap::TL_TRAIL_SAVE); return; } // Map tools > Save
if (!strcmp(name, "savedtrail0")) { navmap::scanTrails(); s_ui->savedTrailPopup(0); return; }
if (!strncmp(name, "trail@", 6)) { // "trail@name.trl" from /sdcard/trails: Load, as the saved-trail popup does
navmap::scanTrails();
for (int i = 0; i < navmap::s_st_n; i++)
@@ -432,6 +432,8 @@ private:
void buildDiag();
void diagSection(int sec);
void refreshDiag();
void sampleHistory(); // DiagScreen.h: History's readings, from loop()
void fillHistory();
void buildCompass();
void refreshCompass();
void favPickPopup(int slot);