feat(ui-lvgl): readable info cards for diagnostics, GPS, node detail and more

Shared infoCard/infoRow/infoNote helpers: muted label left, value right,
hairlines between rows, empty rows hidden. Diagnostics grouped into
sections with a received/sent packet table; GPS gets a position card and
constellation pills; About, OTA, Storage and map popups use the same
cards. Text no longer breaks after '.', clock alarm hint unclipped.
Sim-only sim_open() hook for jumping to a screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-27 00:34:34 +02:00
co-authored by Claude Opus 5.5
parent 23caa11f51
commit 02c2ba1313
9 changed files with 381 additions and 150 deletions
@@ -148,10 +148,14 @@ void UITask::buildClock() {
lv_obj_set_size(col, 120, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_set_style_pad_row(col, 6, 0);
lv_obj_set_style_pad_row(col, 4, 0);
lv_obj_set_style_pad_left(col, 8, 0);
label(col, "Alarm on", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_t* sw = s_alarm_sw = lv_switch_create(col);
lv_obj_t* on = flexBox(col, LV_FLEX_FLOW_ROW); // the switch and its label: one line
lv_obj_set_flex_align(on, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(on, 8, 0);
lv_obj_set_style_pad_bottom(on, 4, 0);
lv_obj_t* sw = s_alarm_sw = lv_switch_create(on);
label(on, "Alarm on", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_set_size(sw, 50, 26);
if (_prefs->alarm_on) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, onAlarmSwitch, LV_EVENT_VALUE_CHANGED, NULL);
+128 -26
View File
@@ -11,7 +11,10 @@ enum : uint8_t { TAB_LIVE, TAB_SYSTEM, TAB_FONT, 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)
static lv_obj_t* s_vals[diag::MAX_ROWS + EXTRA];
// A Live row's value labels: `in` / `out` for a packet count ("12/3" from
// ui-core), else `val` alone.
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;
static void extraRow(diag::Row* rows, int& n, const char* lbl, const char* fmt, ...) {
@@ -44,6 +47,75 @@ static int allRows(diag::Row* rows, bool gps_on) {
return n;
}
// ui-core's labels are cut for a 128 px OLED; here each gets a section and a
// full name. Packet rows are "received/sent" pairs, shown in two columns.
enum : uint8_t { SEC_DEVICE, SEC_PACKETS, SEC_RADIO, SEC_MEMORY, SEC_COUNT };
static const char* const SEC_TITLE[SEC_COUNT] = { "DEVICE", "PACKETS", "RADIO", "MEMORY" };
struct Name { const char* core; uint8_t sec; const char* text; bool pair; };
static const Name NAMES[] = {
{ "Uptime", SEC_DEVICE, "Uptime", false }, { "Last start", SEC_DEVICE, "Last start", false },
{ "Last crash", SEC_DEVICE, "Last crash", false }, { "Power", SEC_DEVICE, "Power", false },
{ "GPS", SEC_DEVICE, "GPS", false },
{ "Total rx/tx", SEC_PACKETS, "All", true }, { "Msg", SEC_PACKETS, "Messages", true },
{ "Advert", SEC_PACKETS, "Adverts", true }, { "Ack/Path", SEC_PACKETS, "Acks & paths", true },
{ "Other", SEC_PACKETS, "Other", true }, { "Forwarded", SEC_PACKETS, "Forwarded", false },
{ "Noise floor", SEC_RADIO, "Noise floor", false }, { "RSSI/SNR", SEC_RADIO, "Last packet", false },
{ "Queue", SEC_RADIO, "Send queue", false }, { "Errors", SEC_RADIO, "Errors", false },
{ "RXPS wd s/h", SEC_RADIO, "RX watchdog soft / hard", false },
{ "Heap free", SEC_MEMORY, "Heap free", false }, { "Stack free", SEC_MEMORY, "Stack free", false },
{ "Pool free", SEC_MEMORY, "Packet pool free", false },
};
static const Name* nameOf(const char* core) {
for (const Name& n : NAMES) if (!strcmp(n.core, core)) return &n;
return nullptr;
}
// A value as shown: "-60/40.0" -> "-60 dBm, SNR 40.0"; "12/456KB" -> "12 of 456 KB".
static void showValue(const char* core, const char* v, char* out, size_t n) {
const char* slash = strchr(v, '/');
if (!strcmp(core, "RSSI/SNR") && slash) snprintf(out, n, "%.*s dBm, SNR %s", (int)(slash - v), v, slash + 1);
else if (!strcmp(core, "Heap free") && slash) snprintf(out, n, "%.*s of %s", (int)(slash - v), v, slash + 1);
else snprintf(out, n, "%s", v);
size_t l = strlen(out); // "KB" / "B" units get their space
if (l > 2 && !strcmp(out + l - 2, "KB") && out[l - 3] != ' ') snprintf(out + l - 2, n - (l - 2), " KB");
else if (l > 1 && out[l - 1] == 'B' && out[l - 2] >= '0' && out[l - 2] <= '9') snprintf(out + l - 1, n - (l - 1), " B");
}
static void setLive(int i, const diag::Row& r) {
LiveVal& lv = s_vals[i];
if (lv.in) {
const char* slash = strchr(r.value, '/');
char a[12];
snprintf(a, sizeof(a), "%.*s", slash ? (int)(slash - r.value) : (int)strlen(r.value), r.value);
lv_label_set_text(lv.in, a);
lv_label_set_text(lv.out, slash ? slash + 1 : "");
return;
}
char v[40];
showValue(r.label, r.value, v, sizeof(v));
lv_label_set_text(lv.val, v);
if (!strcmp(r.label, "Errors")) lv_obj_set_style_text_color(lv.val, lv_color_hex(strcmp(r.value, "OK") ? theme::FAIL : theme::OK), 0);
}
// A packets row: the name, then received and sent in fixed columns.
static const int COL_W = 56;
static lv_obj_t* pairLine(lv_obj_t* card, const char* name, lv_obj_t** in, lv_obj_t** out, bool head) {
lv_obj_t* r = infoLine(card);
lv_obj_t* k = label(r, name, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_align(k, LV_ALIGN_LEFT_MID, 0, 0);
const lv_font_t* f = head ? THEME_FONT_SMALL : THEME_FONT_BODY;
uint32_t col = head ? theme::TEXT_MUTED : theme::TEXT;
*out = label(r, head ? "Sent" : "", f, col);
lv_obj_set_width(*out, COL_W);
lv_obj_set_style_text_align(*out, LV_TEXT_ALIGN_RIGHT, 0);
lv_obj_align(*out, LV_ALIGN_RIGHT_MID, 0, 0);
*in = label(r, head ? "Received" : "", f, col);
lv_obj_set_width(*in, COL_W + 16);
lv_obj_set_style_text_align(*in, LV_TEXT_ALIGN_RIGHT, 0);
lv_obj_align(*in, LV_ALIGN_RIGHT_MID, -COL_W, 0);
return r;
}
} // namespace diagview
static void onOpenDiag(lv_event_t* e) { (void)e; s_ui->showDiag(); }
@@ -75,37 +147,68 @@ void UITask::buildDiag() {
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);
s_list = lv_obj_create(body);
styleSurface(s_list, theme::SURFACE);
lv_obj_set_style_radius(s_list, theme::RADIUS, 0);
lv_obj_set_style_pad_all(s_list, theme::PAD, 0);
lv_obj_set_style_pad_row(s_list, 3, 0);
lv_obj_set_width(s_list, LV_PCT(100));
lv_obj_set_flex_grow(s_list, 1);
lv_obj_set_flex_flow(s_list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_scrollbar_mode(s_list, LV_SCROLLBAR_MODE_ACTIVE);
s_list = scrollList(body);
lv_obj_set_style_pad_row(s_list, 4, 0);
s_rows = 0;
if (s_tab == TAB_LIVE) {
// Section by section, in ui-core's order within each.
diag::Row rows[diag::MAX_ROWS + EXTRA];
s_rows = allRows(rows, _core->gpsEnabled());
for (int i = 0; i < s_rows; i++) {
lv_obj_t* r = lv_obj_create(s_list);
lv_obj_remove_style_all(r);
lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(r, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_align(label(r, rows[i].label, THEME_FONT_BODY, theme::TEXT_MUTED), LV_ALIGN_LEFT_MID, 0, 0);
s_vals[i] = label(r, rows[i].value, THEME_FONT_BODY, theme::TEXT);
lv_obj_align(s_vals[i], LV_ALIGN_RIGHT_MID, 0, 0);
for (uint8_t sec = 0; sec < SEC_COUNT; sec++) {
lv_obj_t* card = nullptr;
for (int i = 0; i < s_rows; i++) {
const Name* nm = nameOf(rows[i].label);
if ((nm ? nm->sec : SEC_DEVICE) != sec) continue;
if (!card) {
sectionTitle(s_list, SEC_TITLE[sec]);
card = infoCard(s_list);
if (sec == SEC_PACKETS) { lv_obj_t *a, *b; pairLine(card, "", &a, &b, true); }
}
s_vals[i] = { nullptr, nullptr, nullptr };
if (nm && nm->pair) pairLine(card, nm->text, &s_vals[i].in, &s_vals[i].out, false);
else s_vals[i].val = infoRow(card, nm ? nm->text : rows[i].label, "");
setLive(i, rows[i]);
}
}
return;
}
diag::Line lines[diag::MAX_LINES];
int n = s_tab == TAB_SYSTEM ? diag::systemLines(lines) : diag::fontLines(lines);
for (int i = 0; i < n; i++) {
lv_obj_t* l = label(s_list, lines[i], s_tab == TAB_FONT ? THEME_FONT_TITLE : THEME_FONT_BODY, theme::TEXT);
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
lv_obj_set_width(l, LV_PCT(100));
lv_obj_t* card = infoCard(s_list);
if (s_tab == TAB_FONT) { // "Latin ABCabc": the script, then its sample
diag::Line lines[diag::MAX_LINES];
int n = diag::fontLines(lines);
for (int i = 0; i < n; i++) {
char* sp = strchr(lines[i], ' ');
if (sp) *sp = '\0';
infoRow(card, lines[i], sp ? sp + 1 : "", theme::TEXT, THEME_FONT_TITLE);
}
return;
}
// System: this build and the radio settings.
char v[48];
diag::shortVersion(v, sizeof(v));
infoRow(card, "Firmware", v);
infoRow(card, "Built", FIRMWARE_BUILD_DATE);
#ifdef MESHCORE_VERSION
infoRow(card, "MeshCore", MESHCORE_VERSION);
#endif
infoRow(card, "Device", board.getManufacturerName());
infoRow(card, "Node name", the_mesh.getNodeName());
if (NodePrefs* p = the_mesh.getNodePrefs()) {
sectionTitle(s_list, "RADIO");
card = infoCard(s_list);
snprintf(v, sizeof(v), "%.3f MHz", p->freq);
infoRow(card, "Frequency", v);
snprintf(v, sizeof(v), "%.1f kHz", p->bw);
if (strstr(v, ".0 ")) snprintf(v, sizeof(v), "%.0f kHz", p->bw);
infoRow(card, "Bandwidth", v);
snprintf(v, sizeof(v), "%u", (unsigned)p->sf);
infoRow(card, "Spreading factor", v);
snprintf(v, sizeof(v), "4/%u", (unsigned)p->cr);
infoRow(card, "Coding rate", v);
snprintf(v, sizeof(v), "%d dBm", (int)p->tx_power_dbm);
infoRow(card, "TX power", v);
}
}
@@ -116,8 +219,7 @@ void UITask::refreshDiag() {
diag::Row rows[diag::MAX_ROWS + EXTRA];
int n = allRows(rows, _core->gpsEnabled());
if (n != s_rows) { buildDiag(); return; }
for (int i = 0; i < n; i++)
lv_label_set_text(s_vals[i], rows[i].value); // unchanged text isn't redrawn (UITask.cpp)
for (int i = 0; i < n; i++) setLive(i, rows[i]); // unchanged text isn't redrawn (UITask.cpp)
}
void UITask::diagResetPopup() {
+88 -45
View File
@@ -19,9 +19,14 @@ static lv_obj_t* s_sky = nullptr; // sky plot (drawn)
static lv_obj_t* s_bars = nullptr; // signal bars (drawn, as wide as the satellites need)
static lv_obj_t* s_status = nullptr;
static lv_obj_t* s_used = nullptr;
static lv_obj_t* s_info = nullptr;
static lv_obj_t* s_systems = nullptr;
static lv_obj_t* s_note = nullptr;
// Beside the plot: in view, search time / time to first fix, UTC.
static lv_obj_t *s_inview = nullptr, *s_search = nullptr, *s_ttff = nullptr, *s_utc = nullptr;
// Under the bars: the position card (hidden without a fix), one pill per
// constellation, the USB warning.
static lv_obj_t *s_fix_title = nullptr, *s_fix_card = nullptr;
static lv_obj_t *s_pos = nullptr, *s_alt = nullptr, *s_speed = nullptr, *s_dop = nullptr;
static lv_obj_t* s_sys_pill[GpsSky::SYS_COUNT];
static lv_obj_t* s_usb = nullptr;
static lv_obj_t* s_on_btn = nullptr;
static const int SKY = 150;
static const int BAR_PITCH = 26, BAR_W = 16, BARS_H = 104;
@@ -273,7 +278,7 @@ void UITask::showGps(bool from_settings) {
void UITask::buildGps() {
using namespace gpsview;
lv_obj_t* body = newScreen("GPS", true);
s_sky = s_bars = s_status = s_used = s_info = s_systems = s_note = s_on_btn = nullptr;
s_sky = s_bars = s_status = s_used = s_on_btn = nullptr;
// Sky plot left, the fix right.
lv_obj_t* top = lv_obj_create(body);
@@ -298,12 +303,16 @@ void UITask::buildGps() {
lv_obj_set_style_pad_top(col, 4, 0);
s_status = label(col, "", THEME_FONT_LARGE, theme::TEXT);
s_used = label(col, "", THEME_FONT_BODY, theme::TEXT);
s_info = label(col, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_set_style_text_line_space(s_info, 2, 0);
for (lv_obj_t* l : { s_used, s_info }) {
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
lv_obj_set_width(l, LV_PCT(100));
}
lv_label_set_long_mode(s_used, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_used, LV_PCT(100));
lv_obj_set_style_pad_bottom(s_used, 4, 0);
lv_obj_t* facts = infoCard(col); // no card of its own: rows on the page
lv_obj_set_style_bg_opa(facts, LV_OPA_TRANSP, 0);
lv_obj_set_style_pad_hor(facts, 0, 0);
s_inview = infoRow(facts, "In view", "", theme::TEXT, THEME_FONT_SMALL);
s_search = infoRow(facts, "Searching", "", theme::TEXT, THEME_FONT_SMALL);
s_ttff = infoRow(facts, "First fix", "", theme::TEXT, THEME_FONT_SMALL);
s_utc = infoRow(facts, "UTC", "", theme::TEXT, THEME_FONT_SMALL);
s_on_btn = lv_button_create(col);
lv_obj_set_height(s_on_btn, 34);
lv_obj_set_style_radius(s_on_btn, theme::RADIUS, 0);
@@ -313,6 +322,16 @@ void UITask::buildGps() {
lv_obj_add_event_cb(s_on_btn, onGpsTurnOn, LV_EVENT_CLICKED, NULL);
lv_obj_add_flag(s_on_btn, LV_OBJ_FLAG_HIDDEN);
s_usb = label(body, LV_SYMBOL_WARNING " On USB power: charging disturbs GPS", THEME_FONT_SMALL, theme::ACCENT);
lv_obj_add_flag(s_usb, LV_OBJ_FLAG_HIDDEN);
s_fix_title = sectionTitle(body, "POSITION");
s_fix_card = infoCard(body);
s_pos = infoRow(s_fix_card, "Coordinates", "");
s_alt = infoRow(s_fix_card, "Altitude", "");
s_speed = infoRow(s_fix_card, "Speed", "");
s_dop = infoRow(s_fix_card, "Precision (DOP)", "");
sectionTitle(body, "SIGNAL (dB-Hz)");
lv_obj_t* sc = lv_obj_create(body); // scrolls sideways when the satellites don't fit
styleSurface(sc, theme::BG);
@@ -326,13 +345,28 @@ void UITask::buildGps() {
lv_obj_set_size(s_bars, BAR_PITCH, BARS_H);
lv_obj_add_event_cb(s_bars, onGpsBarsDraw, LV_EVENT_DRAW_MAIN_END, NULL);
s_systems = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(s_systems, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_systems, LV_PCT(100));
s_note = label(body, "Solid = used in the fix. Green is a strong signal (30+ dB-Hz); "
"indoors, below 25 a fix is unlikely.", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(s_note, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_note, LV_PCT(100));
// Constellations: used / in view, a pill each (a pill never breaks in two).
lv_obj_t* pills = lv_obj_create(body);
lv_obj_remove_style_all(pills);
lv_obj_set_size(pills, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(pills, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_style_pad_column(pills, 6, 0);
lv_obj_set_style_pad_row(pills, 6, 0);
for (uint8_t k = 0; k < GpsSky::SYS_COUNT; k++) {
lv_obj_t* p = label(pills, "", THEME_FONT_SMALL, theme::TEXT);
lv_obj_set_style_bg_color(p, lv_color_hex(theme::SURFACE), 0);
lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0);
lv_obj_set_style_radius(p, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_pad_hor(p, 10, 0);
lv_obj_set_style_pad_ver(p, 3, 0);
lv_obj_add_flag(p, LV_OBJ_FLAG_HIDDEN);
s_sys_pill[k] = p;
}
lv_obj_t* note = label(body, "Solid bar = used in the fix. Green is a strong signal (30+ dB-Hz); "
"indoors, below 25 a fix is unlikely.", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(note, LV_LABEL_LONG_WRAP);
lv_obj_set_width(note, LV_PCT(100));
refreshGps();
}
@@ -348,7 +382,9 @@ void UITask::refreshGps() {
if (off) lv_obj_remove_flag(s_on_btn, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(s_on_btn, LV_OBJ_FLAG_HIDDEN);
char used[48] = "", info[240] = "";
char used[48] = "", inview[16] = "", search[16] = "", ttff[16] = "", utc[16] = "";
char pos[32] = "", alt[16] = "", speed[16] = "", dop[32] = "";
bool fix = false;
if (!g) {
lv_label_set_text(s_status, "Not available");
lv_obj_set_style_text_color(s_status, lv_color_hex(theme::TEXT_MUTED), 0);
@@ -359,51 +395,58 @@ void UITask::refreshGps() {
lv_label_set_text(s_status, "No data");
lv_obj_set_style_text_color(s_status, lv_color_hex(theme::FAIL), 0);
#if defined(SEEED_WIO_TRACKER_L2)
snprintf(info, sizeof(info), "Nothing from the receiver\n(%lu bytes so far)", (unsigned long)gps.rxChars());
snprintf(used, sizeof(used), "Nothing from the receiver (%lu B)", (unsigned long)gps.rxChars());
#else
snprintf(info, sizeof(info), "Nothing from the receiver");
snprintf(used, sizeof(used), "Nothing from the receiver");
#endif
} else {
int tracked = 0, used_n = 0;
for (int i = 0; i < s_n; i++) { if (s_sats[i].snr >= 0) tracked++; if (s_used_flag[i]) used_n++; }
if (g->hasFix()) {
fix = g->hasFix();
if (fix) {
lv_label_set_text(s_status, g->fix_mode == 2 ? "2D fix" : "3D fix");
lv_obj_set_style_text_color(s_status, lv_color_hex(0x6FCF6F), 0);
lv_obj_set_style_text_color(s_status, lv_color_hex(theme::OK), 0);
} else {
lv_label_set_text(s_status, "Searching");
lv_obj_set_style_text_color(s_status, lv_color_hex(theme::ACCENT), 0);
}
snprintf(used, sizeof(used), "%d of %d used", g->hasFix() ? (used_n ? used_n : g->sats_used) : 0, s_n);
int o = snprintf(info, sizeof(info), "%d with a signal\n", tracked);
snprintf(used, sizeof(used), "%d of %d used", fix ? (used_n ? used_n : g->sats_used) : 0, s_n);
snprintf(inview, sizeof(inview), "%d heard", tracked);
uint32_t since = g->start_ms ? (millis() - g->start_ms) / 1000 : 0;
if (g->ttff_ms) o += snprintf(info + o, sizeof(info) - o, "First fix after %lu s\n", (unsigned long)(g->ttff_ms / 1000));
else o += snprintf(info + o, sizeof(info) - o, "Searching for %lu:%02lu\n", (unsigned long)(since / 60), (unsigned long)(since % 60));
if (g->hasFix() && g->hdop > 0)
o += snprintf(info + o, sizeof(info) - o, "DOP H %.1f V %.1f P %.1f\n", g->hdop, g->vdop, g->pdop);
if (g->ttff_ms) snprintf(ttff, sizeof(ttff), "%lu s", (unsigned long)(g->ttff_ms / 1000));
else snprintf(search, sizeof(search), "%lu:%02lu", (unsigned long)(since / 60), (unsigned long)(since % 60));
if (g->utc_valid) snprintf(utc, sizeof(utc), "%02u:%02u:%02u", g->utc_h, g->utc_m, g->utc_s);
int32_t lat, lon;
if (g->hasFix() && _core->course.currentLocation(lat, lon))
o += snprintf(info + o, sizeof(info) - o, "%.5f, %.5f\n", lat / 1e6, lon / 1e6);
if (g->hasFix() && g->alt_valid)
o += snprintf(info + o, sizeof(info) - o, "%.0f m - %.1f km/h\n", g->alt_m, g->speed_kmh);
if (g->utc_valid) o += snprintf(info + o, sizeof(info) - o, "UTC %02u:%02u:%02u", g->utc_h, g->utc_m, g->utc_s);
}
if (g && !off && _board->isExternalPowered()) { // measured: on the cable the L76K never decodes indoors
size_t o = strlen(info);
snprintf(info + o, sizeof(info) - o, "%sOn USB power: charging disturbs GPS", o ? "\n" : "");
if (fix && _core->course.currentLocation(lat, lon)) snprintf(pos, sizeof(pos), "%.5f, %.5f", lat / 1e6, lon / 1e6);
if (fix && g->alt_valid) {
snprintf(alt, sizeof(alt), "%.0f m", g->alt_m);
snprintf(speed, sizeof(speed), "%.1f km/h", g->speed_kmh);
}
if (fix && g->hdop > 0) snprintf(dop, sizeof(dop), "H %.1f V %.1f P %.1f", g->hdop, g->vdop, g->pdop);
}
lv_label_set_text(s_used, used);
lv_label_set_text(s_info, info);
infoSet(s_inview, inview);
infoSet(s_search, search);
infoSet(s_ttff, ttff);
infoSet(s_utc, utc);
infoSet(s_pos, pos);
infoSet(s_alt, alt);
infoSet(s_speed, speed);
infoSet(s_dop, dop);
if (fix && pos[0]) { lv_obj_remove_flag(s_fix_title, LV_OBJ_FLAG_HIDDEN); lv_obj_remove_flag(s_fix_card, LV_OBJ_FLAG_HIDDEN); }
else { lv_obj_add_flag(s_fix_title, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(s_fix_card, LV_OBJ_FLAG_HIDDEN); }
// measured: on the cable the L76K never decodes indoors
if (g && !off && _board->isExternalPowered()) lv_obj_remove_flag(s_usb, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(s_usb, LV_OBJ_FLAG_HIDDEN);
// Per constellation: used / in view.
char sys[160] = "";
int o = 0;
for (uint8_t k = 0; k < GpsSky::SYS_COUNT; k++) {
int inview = 0, u = 0;
for (int i = 0; i < s_n; i++) if (s_sats[i].sys == k) { inview++; if (s_used_flag[i]) u++; }
if (inview) o += snprintf(sys + o, sizeof(sys) - o, "%s%c %s %d/%d", o ? " " : "", GpsSky::sysLetter(k),
GpsSky::sysName(k), u, inview);
int n = 0, u = 0;
for (int i = 0; i < s_n; i++) if (s_sats[i].sys == k) { n++; if (s_used_flag[i]) u++; }
if (!n) { lv_obj_add_flag(s_sys_pill[k], LV_OBJ_FLAG_HIDDEN); continue; }
lv_label_set_text_fmt(s_sys_pill[k], "%c %s %d/%d", GpsSky::sysLetter(k), GpsSky::sysName(k), u, n);
lv_obj_remove_flag(s_sys_pill[k], LV_OBJ_FLAG_HIDDEN);
}
lv_label_set_text(s_systems, sys);
int32_t w = s_n * BAR_PITCH;
if (w < BAR_PITCH) w = BAR_PITCH;
+17 -20
View File
@@ -594,21 +594,27 @@ void UITask::navTargetsPopup() {
if (live == 0) label(list, "Nobody is sharing their position.", THEME_FONT_SMALL, theme::TEXT_MUTED);
}
// A place's coordinates and, with a fix, how far and which way.
void UITask::placeCard(lv_obj_t* parent, int32_t lat, int32_t lon) {
lv_obj_t* card = infoCard(parent);
char t[40];
snprintf(t, sizeof(t), "%.5f, %.5f", lat / 1e6, lon / 1e6);
infoRow(card, "Coordinates", t);
int32_t mlat, mlon;
if (!_core->course.currentLocation(mlat, mlon)) return;
char d[12];
geo::fmtDist(d, sizeof(d), geo::haversineKm(mlat, mlon, lat, lon), _prefs && _prefs->units_imperial);
int az = geo::bearingDeg(mlat, mlon, lat, lon);
snprintf(t, sizeof(t), "%s %d\xC2\xB0 %s", d, az, geo::bearingCardinal(az));
infoRow(card, "Distance", t);
}
void UITask::navWaypointMenu(int idx) {
if (idx < 0 || idx >= _core->waypoints.count()) return;
_nav_wp = idx;
const Waypoint& w = _core->waypoints.at(idx);
lv_obj_t* panel = navPopupPanel(w.label[0] ? w.label : "(unnamed)", false);
char info[80];
int o = snprintf(info, sizeof(info), "%.5f, %.5f", w.lat_1e6 / 1e6, w.lon_1e6 / 1e6);
int32_t lat, lon;
if (_core->course.currentLocation(lat, lon)) {
char d[12];
geo::fmtDist(d, sizeof(d), geo::haversineKm(lat, lon, w.lat_1e6, w.lon_1e6), _prefs && _prefs->units_imperial);
int az = geo::bearingDeg(lat, lon, w.lat_1e6, w.lon_1e6);
snprintf(info + o, sizeof(info) - o, "\n%s %d\xC2\xB0 %s", d, az, geo::bearingCardinal(az));
}
label(panel, info, THEME_FONT_BODY, theme::TEXT);
placeCard(panel, w.lat_1e6, w.lon_1e6);
lv_obj_t* acts = lv_obj_create(panel);
styleSurface(acts, theme::BG);
@@ -1387,16 +1393,7 @@ void UITask::navSpotPopup(int32_t lat, int32_t lon) {
_nav_spot_lat = lat;
_nav_spot_lon = lon;
lv_obj_t* panel = navPopupPanel("This spot", false);
char info[64];
int o = snprintf(info, sizeof(info), "%.5f, %.5f", lat / 1e6, lon / 1e6);
int32_t mlat, mlon;
if (_core->course.currentLocation(mlat, mlon)) {
char d[12];
geo::fmtDist(d, sizeof(d), geo::haversineKm(mlat, mlon, lat, lon), _prefs && _prefs->units_imperial);
int az = geo::bearingDeg(mlat, mlon, lat, lon);
snprintf(info + o, sizeof(info) - o, "\n%s %d\xC2\xB0 %s", d, az, geo::bearingCardinal(az));
}
label(panel, info, THEME_FONT_BODY, theme::TEXT);
placeCard(panel, lat, lon);
lv_obj_t* r = toolRow(panel);
toolButton(r, UI_SYMBOL_FLAG " Add waypoint", navmap::TL_SPOT_ADD, true);
toolButton(r, UI_SYMBOL_COMPASS " Go here", navmap::TL_SPOT_GO, false);
+7 -4
View File
@@ -166,6 +166,8 @@ static bool startTask() {
} // namespace ota
static lv_obj_t* s_ota_latest = nullptr; // the release found, once checked
static void onOpenOta(lv_event_t* e) { (void)e; s_ui->showOta(); }
static void onOtaButton(lv_event_t* e) { (void)e; s_ui->otaAction(); }
@@ -181,10 +183,10 @@ bool UITask::otaBusy() const { return ota::s_state == ota::CHECKING || ota::s_st
void UITask::buildOta() {
lv_obj_t* body = newScreen("Firmware update", true);
char t[96];
snprintf(t, sizeof(t), "Installed: %s", FIRMWARE_VERSION);
label(body, t, THEME_FONT_BODY, theme::TEXT);
_ota_status = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_t* card = infoCard(body);
infoRow(card, "Installed", FIRMWARE_VERSION);
s_ota_latest = infoRow(card, "Latest release", "");
_ota_status = label(body, "", THEME_FONT_BODY, theme::TEXT);
lv_label_set_long_mode(_ota_status, LV_LABEL_LONG_WRAP);
lv_obj_set_width(_ota_status, LV_PCT(100));
_ota_bar = lv_bar_create(body);
@@ -258,6 +260,7 @@ void UITask::refreshOta() {
break;
}
lv_label_set_text(_ota_status, st);
if (s_ota_latest) infoSet(s_ota_latest, s_latest);
lv_label_set_text(_ota_btn_lbl, btn);
if (bar) lv_obj_remove_flag(_ota_bar, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(_ota_bar, LV_OBJ_FLAG_HIDDEN);
if (btn_on) lv_obj_remove_flag(_ota_btn, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(_ota_btn, LV_OBJ_FLAG_HIDDEN);
@@ -163,19 +163,6 @@ static void showUsage(lv_obj_t* bar, lv_obj_t* lbl, uint64_t total, uint64_t use
lv_label_set_text(lbl, line);
}
// Name left, size right.
static lv_obj_t* sizeRow(lv_obj_t* parent, const char* name) {
lv_obj_t* row = lv_obj_create(parent);
styleSurface(row, theme::SURFACE);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(row, LV_PCT(100), 34);
lv_obj_set_style_radius(row, theme::RADIUS, 0);
lv_obj_align(label(row, name, THEME_FONT_BODY, theme::TEXT), LV_ALIGN_LEFT_MID, theme::PAD, 0);
lv_obj_t* v = label(row, "...", THEME_FONT_BODY, theme::ACCENT);
lv_obj_align(v, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
return v;
}
static void refreshSizes() {
for (int c = 0; c < C_COUNT; c++) {
if (!s_cat_val[c]) continue;
@@ -185,7 +172,7 @@ static void refreshSizes() {
}
if (s_status) {
char t[48];
snprintf(t, sizeof(t), s_walking ? "Counting files... %lu" : "%lu files", (unsigned long)s_files);
snprintf(t, sizeof(t), s_walking ? "counting... %lu" : "%lu", (unsigned long)s_files);
lv_label_set_text(s_status, t);
}
}
@@ -220,8 +207,9 @@ void UITask::buildStorage() {
} else {
s_sd_bar = usageBar(body, &s_sd_lbl);
lv_label_set_text(s_sd_lbl, "Reading the card...");
for (int c = 0; c < C_COUNT; c++) s_cat_val[c] = sizeRow(body, CAT_NAME[c]);
s_status = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_t* cats = infoCard(body); // what takes the space
for (int c = 0; c < C_COUNT; c++) s_cat_val[c] = infoRow(cats, CAT_NAME[c], "...", theme::ACCENT);
s_status = infoRow(cats, "Files", "");
s_sd_read = false; // read in pollStorage(): the first read of a big card takes a moment
startWalk();
}
+126 -36
View File
@@ -168,6 +168,76 @@ static lv_obj_t* settingRow(lv_obj_t* parent, const char* text, const char* hint
return row;
}
// ── Info cards ──
// Label / value rows on a card (Diagnostics, GPS, node detail, About): the
// label small and muted on the left, the value on the right -- wrapping,
// right-aligned, when it's long -- and a hairline between rows. infoRow()
// returns the value label to update in place; infoSet() also hides its row
// while there's nothing to show.
static lv_obj_t* infoCard(lv_obj_t* parent) {
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);
lv_obj_set_size(c, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_radius(c, theme::RADIUS, 0);
lv_obj_set_style_pad_hor(c, theme::PAD, 0);
lv_obj_set_flex_flow(c, LV_FLEX_FLOW_COLUMN);
return c;
}
static lv_obj_t* infoLine(lv_obj_t* card) { // one row's box, the hairline above all but the first
lv_obj_t* r = lv_obj_create(card);
lv_obj_remove_style_all(r);
lv_obj_remove_flag(r, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(r, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_ver(r, 5, 0);
if (lv_obj_get_index(r) > 0) {
lv_obj_set_style_border_side(r, LV_BORDER_SIDE_TOP, 0);
lv_obj_set_style_border_width(r, 1, 0);
lv_obj_set_style_border_color(r, lv_color_hex(theme::SURFACE_2), 0);
}
return r;
}
static lv_obj_t* infoRow(lv_obj_t* card, const char* key, const char* value, uint32_t col = theme::TEXT,
const lv_font_t* font = THEME_FONT_BODY) {
lv_obj_t* r = infoLine(card);
lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(r, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(r, 8, 0);
lv_obj_t* k = label(r, key, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(k, LV_LABEL_LONG_WRAP);
lv_obj_set_width(k, 1);
lv_obj_set_flex_grow(k, 1);
lv_obj_t* v = label(r, value, font, col);
lv_label_set_long_mode(v, LV_LABEL_LONG_WRAP);
lv_obj_set_width(v, LV_SIZE_CONTENT);
lv_obj_set_style_max_width(v, LV_PCT(66), 0);
lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0);
return v;
}
// A row whose text is a sentence (credits, notes): the label over it.
static lv_obj_t* infoNote(lv_obj_t* card, const char* key, const char* text) {
lv_obj_t* r = infoLine(card);
lv_obj_set_flex_flow(r, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(r, 2, 0);
label(r, key, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_t* t = label(r, text, THEME_FONT_SMALL, theme::TEXT);
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
lv_obj_set_width(t, LV_PCT(100));
return t;
}
static void infoSet(lv_obj_t* value, const char* text) {
lv_obj_t* row = lv_obj_get_parent(value);
if (!text || !text[0]) { lv_obj_add_flag(row, LV_OBJ_FLAG_HIDDEN); return; }
lv_obj_remove_flag(row, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text(value, text);
}
// A bare flex row / column sized to its content, taps passing through.
static lv_obj_t* flexBox(lv_obj_t* parent, lv_flex_flow_t flow) {
lv_obj_t* b = lv_obj_create(parent);
@@ -1434,6 +1504,8 @@ void UITask::buildContacts() {
// model, since it is a different set: who is in range, not who is known.
static NearbyModel::Entry s_node; // the node open in SCR_NODE (a copy: the list re-sorts)
// Its info card's values (UITask::_node_info is the card).
static lv_obj_t *s_nd_type, *s_nd_status, *s_nd_dist, *s_nd_pos, *s_nd_heard, *s_nd_signal, *s_nd_id;
enum : uint8_t { NODE_MSG, NODE_PING, NODE_FAV, NODE_ADD, NODE_DELETE, NODE_NAV, NODE_ADMIN, NODE_WAYPOINT, NODE_PIN };
static void onNearbyChip(lv_event_t* e) { s_ui->setNearbyFilter((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
@@ -1708,16 +1780,16 @@ void UITask::buildNode() {
const NearbyModel::Entry& e = s_node;
lv_obj_t* body = newScreen(e.name[0] ? e.name : "(unknown)", true);
_node_info = label(body, "", THEME_FONT_BODY, theme::TEXT);
lv_label_set_long_mode(_node_info, LV_LABEL_LONG_WRAP);
lv_obj_set_width(_node_info, LV_PCT(100));
lv_obj_set_style_text_line_space(_node_info, 3, 0);
_node_ping = label(body, "", THEME_FONT_BODY, theme::ACCENT);
lv_obj_t* spacer = lv_obj_create(body); // pushes the actions to the bottom
lv_obj_remove_style_all(spacer);
lv_obj_set_width(spacer, 1);
lv_obj_set_flex_grow(spacer, 1);
lv_obj_t* info = scrollList(body); // the info, scrolling above the actions at the bottom
_node_info = infoCard(info);
s_nd_type = infoRow(_node_info, "Type", "");
s_nd_status = infoRow(_node_info, "Status", "");
s_nd_dist = infoRow(_node_info, "Distance", "");
s_nd_pos = infoRow(_node_info, "Position", "");
s_nd_heard = infoRow(_node_info, "Heard", "");
s_nd_signal = infoRow(_node_info, "Signal", "");
s_nd_id = infoRow(_node_info, "ID", "");
_node_ping = label(info, "", THEME_FONT_BODY, theme::ACCENT);
lv_obj_t* acts = lv_obj_create(body);
styleSurface(acts, theme::BG);
@@ -1763,36 +1835,40 @@ void UITask::refreshNode() {
if (same) { s_node = m; break; }
}
char buf[320];
int o = 0;
// A scan row carries no contact index; is_known says whether it's in the contacts.
bool known = e.contact_idx >= 0 || (_node_from_scan && e.is_known);
o += snprintf(buf + o, sizeof(buf) - o, "%s%s%s%s", NearbyModel::typeName(e.type),
known ? "" : " - not a contact",
e.is_live ? (e.live_verified ? " - live position" : " - live position (channel)") : "",
e.fav ? " - favourite" : "");
char status[64] = "", dist[40] = "", pos[32] = "", heard[16] = "", sig[40] = "", id[12] = "";
int o = 0;
auto tag = [&](const char* t) { o += snprintf(status + o, sizeof(status) - o, "%s%s", o ? ", " : "", t); };
if (!known) tag("not a contact");
if (e.is_live) tag(e.live_verified ? "live position" : "live position (channel)");
if (e.fav) tag("favourite");
if (status[0] >= 'a' && status[0] <= 'z') status[0] -= 'a' - 'A';
int32_t lat, lon;
bool gps = _nearby->ownPosition(lat, lon);
if (e.lat_e6 != 0 || e.lon_e6 != 0) {
if (gps && e.dist_km >= 0.0f) {
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);
o += snprintf(buf + o, sizeof(buf) - o, "\n%s %d\xC2\xB0 %s", d, az, geo::bearingCardinal(az));
snprintf(dist, sizeof(dist), "%s %d\xC2\xB0 %s", d, az, geo::bearingCardinal(az));
}
o += snprintf(buf + o, sizeof(buf) - o, "\n%.5f, %.5f", e.lat_e6 / 1e6, e.lon_e6 / 1e6);
snprintf(pos, sizeof(pos), "%.5f, %.5f", e.lat_e6 / 1e6, e.lon_e6 / 1e6);
} else if (!_node_from_scan) {
o += snprintf(buf + o, sizeof(buf) - o, "\nNo position shared");
snprintf(pos, sizeof(pos), "Not shared");
}
char age[8];
geo::fmtAgeShort(age, sizeof(age), rtc_clock.getCurrentTime(), e.lastmod);
if (age[0]) o += snprintf(buf + o, sizeof(buf) - o, "\nHeard %s ago", age);
if (age[0]) snprintf(heard, sizeof(heard), "%s ago", age);
if (_node_from_scan)
o += snprintf(buf + o, sizeof(buf) - o, "\nRSSI %d dBm - SNR %.1f / %.1f dB", e.rssi,
e.snr_x4 / 4.0f, e.remote_snr_x4 / 4.0f);
if (e.has_prefix)
o += snprintf(buf + o, sizeof(buf) - o, "\nID %02X%02X%02X%02X", e.pub_key[0], e.pub_key[1], e.pub_key[2], e.pub_key[3]);
lv_label_set_text(_node_info, buf);
snprintf(sig, sizeof(sig), "%d dBm, SNR %.1f / %.1f", e.rssi, e.snr_x4 / 4.0f, e.remote_snr_x4 / 4.0f);
if (e.has_prefix) snprintf(id, sizeof(id), "%02X%02X%02X%02X", e.pub_key[0], e.pub_key[1], e.pub_key[2], e.pub_key[3]);
infoSet(s_nd_type, NearbyModel::typeName(e.type));
infoSet(s_nd_status, status);
infoSet(s_nd_dist, dist);
infoSet(s_nd_pos, pos);
infoSet(s_nd_heard, heard);
infoSet(s_nd_signal, sig);
infoSet(s_nd_id, id);
// Ping result (PingEngine releases its slot on reply; the view owns the timeout)
if (_pinging) {
@@ -2721,15 +2797,13 @@ void UITask::buildSettings() {
}
sectionTitle(body, "ABOUT");
char about[300], built[24] = "";
if (!strstr(FIRMWARE_VERSION, FIRMWARE_BUILD_DATE)) snprintf(built, sizeof(built), " (%s)", FIRMWARE_BUILD_DATE);
snprintf(about, sizeof(about), "%s\nFirmware %s%s\n\nMap data: %s\nEmoji: Twemoji \xC2\xA9 Twitter, Inc. and contributors (CC\xE2\x80\x91" "BY 4.0)",
the_mesh.getNodeName(), FIRMWARE_VERSION, built,
(lvport::mountStorage() && mapview::s_provider->available()) ? mapview::s_provider->attribution()
: "\xC2\xA9 OpenStreetMap contributors");
lv_obj_t* a = label(body, about, THEME_FONT_SMALL, theme::TEXT);
lv_label_set_long_mode(a, LV_LABEL_LONG_WRAP);
lv_obj_set_width(a, LV_PCT(100));
lv_obj_t* about = infoCard(body);
infoRow(about, "Node", the_mesh.getNodeName());
infoRow(about, "Firmware", FIRMWARE_VERSION);
if (!strstr(FIRMWARE_VERSION, FIRMWARE_BUILD_DATE)) infoRow(about, "Built", FIRMWARE_BUILD_DATE);
infoNote(about, "Map data", (lvport::mountStorage() && mapview::s_provider->available())
? mapview::s_provider->attribution() : "\xC2\xA9 OpenStreetMap contributors");
infoNote(about, "Emoji", "Twemoji \xC2\xA9 Twitter, Inc. and contributors (CC\xE2\x80\x91" "BY\xC2\xA0" "4.0)");
}
void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) {
@@ -2751,3 +2825,19 @@ void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) {
#include "OtaScreen.h"
#include "StorageScreen.h"
#include "Splash.h"
#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
// Sim tests: straight to a screen by name.
extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) {
if (!s_ui) return;
struct { const char* n; void (UITask::*fn)(); } const SCREENS[] = {
{ "home", &UITask::showHome }, { "chats", &UITask::showChats }, { "settings", &UITask::showSettings },
{ "nodes", &UITask::showNearby }, { "ota", &UITask::showOta }, { "wifi", &UITask::showWifi },
{ "clock", &UITask::showClock }, { "radio", &UITask::showRadio }, { "scopes", &UITask::showScopes },
{ "repeater", &UITask::showRepeater }, { "bot", &UITask::showBot }, { "storage", &UITask::showStorage },
{ "diag", &UITask::showDiag }, { "compass", &UITask::showCompass }, { "admin", &UITask::showAdminPick },
{ "quick", &UITask::showQuickMsgs },
};
for (auto& s : SCREENS) if (!strcmp(s.n, name)) { (s_ui->*s.fn)(); return; }
}
#endif
@@ -303,6 +303,7 @@ private:
void navAddWaypoint(int32_t lat, int32_t lon);
void navDropAt(int x, int y);
void navSpotPopup(int32_t lat, int32_t lon);
void placeCard(lv_obj_t* parent, int32_t lat, int32_t lon);
void refreshNavTools();
void navPollAveraging();
void navPollTrackBack();
@@ -39,6 +39,9 @@
// Image glyphs (the emoji) are drawn only with this on; lv_imgfont itself is unused.
#define LV_USE_IMGFONT 1
#define LV_FONT_DEFAULT &ui_font_14
// Where a line may break: not after '.', which would split "4.0", "v1.23" or
// a coordinate across two lines.
#define LV_TXT_BREAK_CHARS " ,;:-_)]}"
#define LV_USE_FONT_COMPRESSED 0 // fonts are generated uncompressed: no per-glyph decompression on every draw
// PNG decoder for raster map tiles (map/TileProvider.h calls lodepng directly).