mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-11-01 10:21:59 +00:00
feat(eink): screens laid out for the 4.2" -- tiles, Nodes pane, charts, Sky, nav, clock tools
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -185,15 +185,37 @@ class ClockToolsScreen : public UIScreen {
|
||||
return (mq_delay > 0 && mq_delay < 60000) ? mq_delay : 60000;
|
||||
}
|
||||
|
||||
// On a screen 40 characters wide (the 4.2"): `t` in the big clock's
|
||||
// lettering, as large as fits between the header and the hint line, with
|
||||
// character `cur` underlined (-1: none). False elsewhere: draw it in text.
|
||||
bool bigDigits(DisplayDriver& d, const char* t, int cur) {
|
||||
if (d.width() < 40 * d.getCharWidth()) return false;
|
||||
const int lh = d.getLineHeight(), top = d.listStart(), bottom = d.height() - 2 * lh;
|
||||
int sc = 6; // the Home clock's size at most
|
||||
while (sc > 1 && (info::letteringW(t, sc, sc + 1) > d.width() - 2 * lh
|
||||
|| lettering::LOGO_H * sc > bottom - top - lh)) sc--;
|
||||
const int gap = sc + 1, h = lettering::LOGO_H * sc;
|
||||
const int x0 = (d.width() - info::letteringW(t, sc, gap)) / 2, y = top + (bottom - top - h) / 2;
|
||||
info::lettering(d, x0, y, t, sc, gap);
|
||||
if (cur >= 0) {
|
||||
int x = x0;
|
||||
for (int i = 0; i < cur && t[i]; i++) x += lettering::charW(t[i]) * sc + gap;
|
||||
d.fillRect(x, y + h + sc, lettering::charW(t[cur]) * sc, sc);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int renderTimer(DisplayDriver& d) {
|
||||
drawScreenHeader(d, "Timer");
|
||||
const int cx = d.width() / 2;
|
||||
if (_task->isTimerRunning()) {
|
||||
char buf[16];
|
||||
fmtHMS(buf, sizeof(buf), _task->timerRemainingMs());
|
||||
if (!bigDigits(d, buf, -1)) {
|
||||
d.setTextSize(2);
|
||||
d.drawTextCentered(cx, d.listStart() + 4, buf);
|
||||
d.setTextSize(1);
|
||||
}
|
||||
d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, "Ent=stop");
|
||||
return liveTickMs(d, 1000);
|
||||
}
|
||||
@@ -201,6 +223,10 @@ class ClockToolsScreen : public UIScreen {
|
||||
// LEFT/RIGHT move the cursor one digit, UP/DOWN change it, Enter starts.
|
||||
char buf[12];
|
||||
snprintf(buf, sizeof(buf), "%02u:%02u:%02u", _timer_h, _timer_m, _timer_s);
|
||||
if (bigDigits(d, buf, _timer_cur + (_timer_cur / 2))) {
|
||||
d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, "Up/Dn=set Ent=start");
|
||||
return 60000;
|
||||
}
|
||||
d.setTextSize(2);
|
||||
const int ty = d.listStart() + 2;
|
||||
d.drawTextCentered(cx, ty, buf);
|
||||
@@ -220,9 +246,11 @@ class ClockToolsScreen : public UIScreen {
|
||||
char buf[16];
|
||||
bool tenths = !d.isEink() && _sw_running; // tenths only on a fast panel
|
||||
fmtStopwatch(buf, sizeof(buf), stopwatchMs(), tenths);
|
||||
if (!bigDigits(d, buf, -1)) {
|
||||
d.setTextSize(2);
|
||||
d.drawTextCentered(cx, d.listStart() + 4, buf);
|
||||
d.setTextSize(1);
|
||||
}
|
||||
const char* hint = _sw_running ? "Ent=stop" : "Ent=start Dn=reset";
|
||||
d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, hint);
|
||||
return _sw_running ? liveTickMs(d, 100) : 60000;
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
// fixed 128x64, so a larger-font e-ink layout gets them scaled for free.
|
||||
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include "icons.h"
|
||||
#include "../ui-core/StatusHistory.h"
|
||||
#include "../ui-core/Lettering.h"
|
||||
|
||||
namespace info {
|
||||
|
||||
@@ -62,8 +64,9 @@ inline void section(DisplayDriver& d, int y, const char* label, int reserve = 0)
|
||||
}
|
||||
|
||||
// A battery glyph the size of a mini icon (7 x 5 at 1x, 9 x 7 in the large
|
||||
// set), its corners softly rounded, filled to frac.
|
||||
static constexpr int BATT_BODY_W = MINI_ICONS_LARGE ? 8 : 6, BATT_BODY_H = MINI_ICONS_LARGE ? 7 : 5;
|
||||
// set, 12 x 9 in the 9 px one), its corners softly rounded, filled to frac.
|
||||
static constexpr int BATT_BODY_W = MINI_ICONS_LARGE == 2 ? 11 : MINI_ICONS_LARGE ? 8 : 6,
|
||||
BATT_BODY_H = MINI_ICONS_LARGE == 2 ? 9 : MINI_ICONS_LARGE ? 7 : 5;
|
||||
inline int batteryW(DisplayDriver& d) { return (BATT_BODY_W + 1) * miniIconScale(d); }
|
||||
inline int batteryH(DisplayDriver& d) { return BATT_BODY_H * miniIconScale(d); }
|
||||
inline void battery(DisplayDriver& d, int x, int y, float frac) {
|
||||
@@ -297,26 +300,44 @@ inline void fadeColumn(DisplayDriver& d, int x, int y0, int y1) {
|
||||
// or, with `bars`, one bar each from the bottom, for counts -- the newest at
|
||||
// the right edge, then a caption line under the frame: `left` at its left
|
||||
// (how far back it goes), `right` at its right. chartH() is the whole
|
||||
// block's height.
|
||||
// block's height. `axis`, on a chart 30 characters wide or more (the 4.2"
|
||||
// e-ink), prints the top, middle and bottom values in a column to the left
|
||||
// of the frame: it writes one sample value in the unit the reader knows.
|
||||
inline int chartH(DisplayDriver& d, int frame_h) { return frame_h + d.getLineHeight() + 3; }
|
||||
typedef void (*ChartAxisFmt)(char* buf, int n, int v);
|
||||
template <int N>
|
||||
inline void chart(DisplayDriver& d, int x, int y, int w, int h, const History<N>& hs, int min_span,
|
||||
bool bars, const char* left, const char* right) {
|
||||
bool bars, const char* left, const char* right, ChartAxisFmt axis = nullptr) {
|
||||
const bool empty = hs.n < (bars ? 1 : 2);
|
||||
int lo = 0, hi = 0;
|
||||
if (!empty) {
|
||||
lo = bars ? 0 : hs.at(0); hi = lo;
|
||||
for (int i = 0; i < hs.n; i++) { int v = hs.at(i); if (v < lo) lo = v; if (v > hi) hi = v; }
|
||||
if (hi - lo < min_span) { if (bars) hi = lo + min_span; else { int mid = (hi + lo) / 2; lo = mid - min_span / 2; hi = lo + min_span; } }
|
||||
}
|
||||
const int lh = d.getLineHeight();
|
||||
if (axis && !empty && w >= 30 * d.getCharWidth() && h >= 2 * lh) {
|
||||
char t[3][12];
|
||||
const int v[3] = { hi, (hi + lo) / 2, lo };
|
||||
int gw = 0;
|
||||
for (int k = 0; k < 3; k++) { axis(t[k], sizeof(t[k]), v[k]); int tw = d.getTextWidth(t[k]); if (tw > gw) gw = tw; }
|
||||
d.drawTextRightAlign(x + gw, y + 1, t[0]);
|
||||
if (h >= 3 * lh) d.drawTextRightAlign(x + gw, y + (h - lh) / 2, t[1]);
|
||||
d.drawTextRightAlign(x + gw, y + h - lh - 1, t[2]);
|
||||
x += gw + 3; w -= gw + 3;
|
||||
}
|
||||
d.drawSoftRect(x, y, w, h);
|
||||
const int s = miniIconScale(d);
|
||||
for (int i = x + 3; i < x + w - 3; i += 3 * s) d.fillRect(i, y + h / 2, s, s);
|
||||
const int ix = x + 2, iy = y + 2, iw = w - 4, ih = h - 4;
|
||||
if (hs.n < (bars ? 1 : 2)) { // nothing to draw yet: say so over the line
|
||||
const int tw = d.getTextWidth("no data"), tx = x + (w - tw) / 2, ty = y + (h - d.getLineHeight()) / 2;
|
||||
if (empty) { // nothing to draw yet: say so over the line
|
||||
const int tw = d.getTextWidth("no data"), tx = x + (w - tw) / 2, ty = y + (h - lh) / 2;
|
||||
d.setColor(DisplayDriver::DARK);
|
||||
d.fillRect(tx - 2, ty, tw + 4, d.getLineHeight());
|
||||
d.fillRect(tx - 2, ty, tw + 4, lh);
|
||||
d.setColor(DisplayDriver::LIGHT);
|
||||
d.setCursor(tx, ty);
|
||||
d.print("no data");
|
||||
} else {
|
||||
int lo = bars ? 0 : hs.at(0), hi = lo;
|
||||
for (int i = 0; i < hs.n; i++) { int v = hs.at(i); if (v < lo) lo = v; if (v > hi) hi = v; }
|
||||
if (hi - lo < min_span) { if (bars) hi = lo + min_span; else { int mid = (hi + lo) / 2; lo = mid - min_span / 2; hi = lo + min_span; } }
|
||||
const int bw = iw / N > 1 ? iw / N : 1;
|
||||
int px = -1, py = 0;
|
||||
for (int i = 0; i < hs.n; i++) {
|
||||
@@ -339,6 +360,33 @@ inline void chart(DisplayDriver& d, int x, int y, int w, int h, const History<N>
|
||||
if (right && *right) d.drawTextRightAlign(x + w, ty, right);
|
||||
}
|
||||
|
||||
// Axis formats for chart(): the unit each History is kept in, as read.
|
||||
inline void axisPlain(char* b, int n, int v) { snprintf(b, n, "%d", v); }
|
||||
inline void axisVolts(char* b, int n, int v) { snprintf(b, n, "%.2f", v / 1000.0f); } // mV
|
||||
inline void axisQuarterDb(char* b, int n, int v) { snprintf(b, n, "%.0f", v / 4.0f); } // dB x4
|
||||
|
||||
// Text in the splash's slanted lettering (ui-core/Lettering.h: digits, ':',
|
||||
// '.', '-' and a few letters) at `sc` px a pixel, `gap` px between
|
||||
// characters, its top-left at x / y. letteringW() measures it the same way.
|
||||
inline void lettering(DisplayDriver& d, int x, int y, const char* t, int sc, int gap) {
|
||||
for (const char* p = t; *p; p++) {
|
||||
const int cw = ::lettering::charW(*p);
|
||||
if (cw < 0) continue;
|
||||
for (int r = 0; r < ::lettering::LOGO_H; r++)
|
||||
for (int col = 0; col < cw; col++) {
|
||||
if (!::lettering::inked(*p, col, r)) continue;
|
||||
int e = col;
|
||||
while (e + 1 < cw && ::lettering::inked(*p, e + 1, r)) e++;
|
||||
d.fillRect(x + col * sc, y + r * sc, (e - col + 1) * sc, sc);
|
||||
col = e;
|
||||
}
|
||||
x += cw * sc + gap;
|
||||
}
|
||||
}
|
||||
inline int letteringW(const char* t, int sc, int gap) {
|
||||
return ::lettering::textW(t, 0) * sc + gap * ((int)strlen(t) - 1);
|
||||
}
|
||||
|
||||
// Blocks stacked down from `top`, with the first `skip` left out: the way a
|
||||
// tab scrolls by whole blocks. Call place(h) before drawing each block; it
|
||||
// says whether the block is on screen and where (at). The totals then give
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include "../GeoUtils.h"
|
||||
#include "../ui-core/EtaTracker.h" // navview::EtaTracker (shared with ui-lvgl)
|
||||
#include "InfoKit.h" // info::rose()
|
||||
|
||||
namespace navview {
|
||||
|
||||
@@ -42,12 +43,33 @@ inline void draw(DisplayDriver& d,
|
||||
|
||||
const int step = d.lineStep();
|
||||
int y = d.listStart();
|
||||
// A screen 40 characters wide (the 4.2"): the lines down the left, and a
|
||||
// compass rose (north up) at the right with an arrow to the target and a
|
||||
// mark on the rim where you're heading.
|
||||
const bool wide = d.width() >= 40 * d.getCharWidth();
|
||||
|
||||
// Distance — emphasised at size 2.
|
||||
d.setTextSize(2);
|
||||
d.drawTextCentered(cx, y, dist);
|
||||
if (wide) { d.setCursor(2, y); d.print(dist); }
|
||||
else d.drawTextCentered(cx, y, dist);
|
||||
y += d.getLineHeight() + 3;
|
||||
d.setTextSize(1);
|
||||
if (wide) {
|
||||
const int lh = d.getLineHeight(), top = d.listStart();
|
||||
const int r = (d.height() - top) / 2 - lh;
|
||||
const int rx = d.width() - r - lh, ry = top + (d.height() - top) / 2 + lh / 4;
|
||||
info::rose(d, rx, ry, r, to_deg);
|
||||
if (cog_valid) { // a small filled triangle outside the rim, pointing in
|
||||
const float a = cog_deg * (float)M_PI / 180.0f, sa = sinf(a), ca = cosf(a);
|
||||
const int s = miniIconScale(d) * 2;
|
||||
for (int k = 0; k <= 3 * s; k++) {
|
||||
const float rr = r + 2 + k, half = k / 2.0f;
|
||||
const int px = rx + (int)lroundf(rr * sa), py = ry - (int)lroundf(rr * ca);
|
||||
info::line(d, px - (int)lroundf(half * ca), py - (int)lroundf(half * sa),
|
||||
px + (int)lroundf(half * ca), py + (int)lroundf(half * sa));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
char line[20];
|
||||
snprintf(line, sizeof(line), "To: %d %s", to_deg, geo::bearingCardinal(to_deg));
|
||||
|
||||
@@ -434,23 +434,29 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
|
||||
// ── detail rendering ──────────────────────────────────────────────────────────
|
||||
void renderStoredDetail(DisplayDriver& display) {
|
||||
const Entry& e = _entries[_sel];
|
||||
const int hdr = display.listStart(); // content top (gap below the header separator)
|
||||
display.drawInvertedHeader(e.name, true, ctxMenuOpen());
|
||||
drawStoredFields(display, e, 0, display.listStart(), display.width(), display.height());
|
||||
}
|
||||
|
||||
// A stored node's position, distance, type, age and path, in the box from
|
||||
// x0 / top, w wide down to bottom: the detail view's body, and on a wide
|
||||
// screen the pane beside the list.
|
||||
void drawStoredFields(DisplayDriver& display, const Entry& e, int x0, int top, int w, int bottom) {
|
||||
const int tx = x0 + 2;
|
||||
int step = display.lineStep();
|
||||
if (step * 5 > display.height() - hdr) step = (display.height() - hdr) / 5;
|
||||
if (step * 5 > bottom - top) step = (bottom - top) / 5;
|
||||
char buf[32];
|
||||
// Without a line to spare for the path (the OLED), the position takes one
|
||||
// row, unlabelled: then there are five rows either way.
|
||||
const bool one_pos = hdr + step * 5 + display.getLineHeight() > display.height();
|
||||
const bool one_pos = top + step * 5 + display.getLineHeight() > bottom;
|
||||
if (one_pos) {
|
||||
snprintf(buf, sizeof(buf), "%.5f,%.5f", e.lat_e6 / 1e6, e.lon_e6 / 1e6);
|
||||
display.drawTextEllipsized(2, hdr, display.width() - 4, buf);
|
||||
display.drawTextEllipsized(tx, top, w - 4, buf);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6);
|
||||
display.setCursor(2, hdr); display.print(buf);
|
||||
display.setCursor(tx, top); display.print(buf);
|
||||
snprintf(buf, sizeof(buf), "Lon: %.5f", e.lon_e6 / 1e6);
|
||||
display.setCursor(2, hdr + step); display.print(buf);
|
||||
display.setCursor(tx, top + step); display.print(buf);
|
||||
}
|
||||
const int r0 = one_pos ? -1 : 0; // the rows under the position move up one
|
||||
|
||||
@@ -462,34 +468,34 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "Dist: no GPS");
|
||||
}
|
||||
display.setCursor(2, hdr + step * (2 + r0)); display.print(buf);
|
||||
display.setCursor(tx, top + step * (2 + r0)); display.print(buf);
|
||||
snprintf(buf, sizeof(buf), "Type: %s", typeName(e.type));
|
||||
display.setCursor(2, hdr + step * (3 + r0)); display.print(buf);
|
||||
display.setCursor(tx, top + step * (3 + r0)); display.print(buf);
|
||||
char age[16];
|
||||
fmtAge(age, sizeof(age), e.lastmod);
|
||||
// For a live [LOC] row, label the timestamp as a position share and note
|
||||
// whether the sender's identity is verified (DM) or name-only (channel).
|
||||
if (e.is_live) snprintf(buf, sizeof(buf), "Sharing pos: %s %s", age, e.live_verified ? "(DM)" : "(chan)");
|
||||
else snprintf(buf, sizeof(buf), "Seen: %s", age);
|
||||
display.drawTextEllipsized(2, hdr + step * (4 + r0), display.width() - 4, buf);
|
||||
display.drawTextEllipsized(tx, top + step * (4 + r0), w - 4, buf);
|
||||
|
||||
const int W = display.width(), H = display.height(), lh = display.getLineHeight();
|
||||
const int lh = display.getLineHeight();
|
||||
int rows = 5 + r0;
|
||||
// How a message gets there: a chain from you to it, a ring per repeater
|
||||
// on the way.
|
||||
ContactInfo ci;
|
||||
if (hdr + step * rows + lh <= H && e.contact_idx >= 0 && the_mesh.getContactByIdx(e.contact_idx, ci)) {
|
||||
const int y = hdr + step * rows;
|
||||
display.setCursor(2, y);
|
||||
if (top + step * rows + lh <= bottom && e.contact_idx >= 0 && the_mesh.getContactByIdx(e.contact_idx, ci)) {
|
||||
const int y = top + step * rows;
|
||||
display.setCursor(tx, y);
|
||||
display.print("Path:");
|
||||
int x = 2 + display.getTextWidth("Path: ");
|
||||
int x = tx + display.getTextWidth("Path: ");
|
||||
if (ci.out_path_len == 0xFF) {
|
||||
display.print(" flood");
|
||||
} else {
|
||||
const int hops = ci.out_path_len & 63;
|
||||
const int r = lh / 4 > 2 ? lh / 4 : 2, cy = y + lh / 2 - 1, gap = r + 3;
|
||||
// up to 5 rings, as many as leave room for the count after them
|
||||
const int fit = (W - 2 - x - display.getTextWidth("5 hops")) / (2 * r + 1 + gap) - 2;
|
||||
const int fit = (x0 + w - 2 - x - display.getTextWidth("5 hops")) / (2 * r + 1 + gap) - 2;
|
||||
const int cap = fit < 5 ? (fit < 1 ? 1 : fit) : 5, shown = hops > cap ? cap : hops;
|
||||
for (int k = 0; k <= shown + 1; k++) { // you and it filled, the hops as rings
|
||||
if (k) for (int dx = x - gap + 1; dx < x; dx += 2) display.fillRect(dx, cy, 1, 1);
|
||||
@@ -508,11 +514,11 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
|
||||
// Where it is, on a compass rose (north up), where the screen has the room
|
||||
// (e-ink): beside the rows on a wide one, under them on a tall one.
|
||||
if (!Features::IS_EINK || e.dist_km < 0.0f) return;
|
||||
const int below = hdr + step * rows + lh;
|
||||
const int below = top + step * rows + lh;
|
||||
int r, cx, cy;
|
||||
if (H - below > W / 2) { r = (W < H - below ? W : H - below) / 2 - lh; cx = W / 2; cy = below + lh / 2 + r; }
|
||||
else { r = (H - hdr) / 2 - lh / 2 - 1; cx = W - r - 3; cy = hdr + lh / 2 + r; }
|
||||
const int text_r = 2 + display.getTextWidth("Lon: -000.00000");
|
||||
if (bottom - below > w / 2) { r = (w < bottom - below ? w : bottom - below) / 2 - lh; cx = x0 + w / 2; cy = below + lh / 2 + r; }
|
||||
else { r = (bottom - top) / 2 - lh / 2 - 1; cx = x0 + w - r - 3; cy = top + lh / 2 + r; }
|
||||
const int text_r = tx + display.getTextWidth("Lon: -000.00000");
|
||||
if (r < 2 * lh || (cy - r < below - lh && cx - r < text_r)) return;
|
||||
info::rose(display, cx, cy, r, geo::bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6));
|
||||
}
|
||||
@@ -661,10 +667,13 @@ public:
|
||||
_list_refresh_ms = millis();
|
||||
}
|
||||
|
||||
int item_h = display.lineStep();
|
||||
// A screen 40 characters wide (the 4.2" e-ink) keeps the list to the left
|
||||
// and shows the highlighted node's detail beside it.
|
||||
const bool split = _source == SRC_STORED && _count > 0 && display.width() >= 40 * display.getCharWidth();
|
||||
const int lw = split ? display.width() * 11 / 20 : display.width(); // the list's right edge
|
||||
// An arrow towards the node before its distance: one more cell.
|
||||
const bool arrows = _source == SRC_STORED && _sort != SORT_TIME;
|
||||
int dist_col = display.width() - display.getCharWidth() * (arrows ? 9 : 7);
|
||||
int dist_col = lw - display.getCharWidth() * (arrows ? 9 : 7);
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
const char* flt = (_filter != F_ALL) ? filterLabel(_filter) : nullptr;
|
||||
@@ -708,7 +717,7 @@ public:
|
||||
drawList(display, _count, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {
|
||||
const Entry& e = _entries[idx];
|
||||
|
||||
drawRowSelection(display, y, sel, reserve);
|
||||
drawRowSelection(display, y, sel, reserve, lw);
|
||||
|
||||
char filt[32];
|
||||
int tx = 2;
|
||||
@@ -737,7 +746,7 @@ public:
|
||||
display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT);
|
||||
char right[10];
|
||||
if (_source == SRC_SCAN) { // live scan: how well we hear it, as bars
|
||||
drawSignalBars(display, display.width() - reserve - 2, y, e.snr_x4);
|
||||
drawSignalBars(display, lw - reserve - 2, y, e.snr_x4);
|
||||
right[0] = '\0';
|
||||
} else if (_sort == SORT_TIME) {
|
||||
geo::fmtAgeShort(right, sizeof(right), rtc_clock.getCurrentTime(), e.lastmod);
|
||||
@@ -746,14 +755,21 @@ public:
|
||||
if (e.dist_km >= 0.0f) geo::fmtDist(right, sizeof(right), e.dist_km, useImperial());
|
||||
else strcpy(right, "-"); // no fix of ours or theirs
|
||||
}
|
||||
if (right[0]) display.drawTextRightAlign(display.width() - reserve - 2, y, right);
|
||||
if (right[0]) display.drawTextRightAlign(lw - reserve - 2, y, right);
|
||||
if (arrows && e.dist_km >= 0.0f) {
|
||||
const int deg = geo::bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6);
|
||||
const MiniIcon& ic = *ICON_ARROWS[((deg + 22) % 360) / 45];
|
||||
miniIconDraw(display, display.width() - reserve - 2 - display.getTextWidth(right) - 3
|
||||
miniIconDraw(display, lw - reserve - 2 - display.getTextWidth(right) - 3
|
||||
- ic.w * miniIconScale(display), y, ic);
|
||||
}
|
||||
});
|
||||
}, lw);
|
||||
if (split && _sel < _count) {
|
||||
const int top = display.listStart();
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(lw + 2, top, display.sepH(), display.height() - top);
|
||||
drawStoredFields(display, _entries[_sel], lw + 2 + display.sepH() + 2, top,
|
||||
display.width() - lw - 4 - display.sepH(), display.height());
|
||||
}
|
||||
}
|
||||
|
||||
if (renderActivePopup(display)) return 50;
|
||||
|
||||
@@ -82,16 +82,24 @@ public:
|
||||
|
||||
const int lh = display.getLineHeight();
|
||||
const int cw = display.getCharWidth();
|
||||
const int cell_w = cw * 2 + 6; // fits 2-char label with margin
|
||||
const bool wide = display.width() >= 40 * cw;
|
||||
const int cell_w = cw * 2 + (wide ? 12 : 6); // fits 2-char label with margin
|
||||
const int notes_y = display.listStart();
|
||||
const int cell_h = lh + 6;
|
||||
_visible_notes = display.width() / cell_w;
|
||||
|
||||
char hdr[32];
|
||||
// A screen 40 characters wide (the 4.2"): a titled header, and the
|
||||
// melody drawn as a roll under the notes (see drawRoll()).
|
||||
char hdr[40];
|
||||
if (wide) {
|
||||
snprintf(hdr, sizeof(hdr), "Melody %d %u BPM %d/%d", _slot + 1, soundctl::bpm(_bpm_idx), _len, MAX_NOTES);
|
||||
drawScreenHeader(display, hdr, -1, 0, true, _menu.active);
|
||||
} else {
|
||||
snprintf(hdr, sizeof(hdr), "M%d BPM:%u %d/%d", _slot + 1, soundctl::bpm(_bpm_idx), _len, MAX_NOTES);
|
||||
display.setCursor(0, 0);
|
||||
display.print(hdr);
|
||||
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
|
||||
}
|
||||
|
||||
for (int i = 0; i < _visible_notes; i++) {
|
||||
int ni = _scroll + i;
|
||||
@@ -142,14 +150,42 @@ public:
|
||||
display.print("U/D to add note");
|
||||
}
|
||||
|
||||
if (wide) drawRoll(display, info_y + lh + 6, bottom_y - 6, cell_w);
|
||||
display.setCursor(0, bottom_y);
|
||||
display.print("ENT:oct MENU:opts");
|
||||
display.print(wide ? "Up/Down: note Enter: octave Hold: options" : "ENT:oct MENU:opts");
|
||||
|
||||
if (_menu.active) _menu.render(display);
|
||||
|
||||
return 200;
|
||||
}
|
||||
|
||||
// The visible notes as a roll from y0 to y1: under each note's cell a bar
|
||||
// as high as its pitch (C4 at the bottom to B6 at the top, a dotted line
|
||||
// at each C) and as wide as its length (a 1/4 fills the cell); a rest is
|
||||
// a dash on the floor. The selected note's column is marked at its sides.
|
||||
void drawRoll(DisplayDriver& d, int y0, int y1, int cell_w) {
|
||||
const int steps = (soundctl::OCT_MAX - soundctl::OCT_MIN + 1) * 7; // C4..B6
|
||||
const int h = y1 - y0, W = _visible_notes * cell_w;
|
||||
if (h < steps * 2) return;
|
||||
const int bar_h = h / steps > 5 ? 5 : h / steps > 2 ? h / steps : 2;
|
||||
d.setColor(DisplayDriver::LIGHT);
|
||||
for (int o = 0; o <= soundctl::OCT_MAX - soundctl::OCT_MIN; o++) {
|
||||
const int y = y1 - (o * 7) * h / steps;
|
||||
for (int x = 0; x < W; x += 3) d.fillRect(x, y, 1, 1);
|
||||
}
|
||||
for (int i = 0; i < _visible_notes; i++) {
|
||||
const int ni = _scroll + i, cx = i * cell_w;
|
||||
if (ni == _cursor)
|
||||
for (int y = y0; y < y1; y += 2) { d.fillRect(cx, y, 1, 1); d.fillRect(cx + cell_w - 2, y, 1, 1); }
|
||||
if (ni >= _len) continue;
|
||||
const uint8_t p = notePitch(_notes[ni]);
|
||||
const int w = (cell_w - 4) >> noteDurIdx(_notes[ni]);
|
||||
if (p == 0) { d.fillRect(cx + 2, y1 - 1, w > 2 ? w : 2, 1); continue; }
|
||||
const int k = (noteOctave(_notes[ni]) - soundctl::OCT_MIN) * 7 + p - 1;
|
||||
d.fillRect(cx + 2, y1 - (k + 1) * h / steps, w > 2 ? w : 2, bar_h);
|
||||
}
|
||||
}
|
||||
|
||||
void cycleMenuValue(int sel, int dir) {
|
||||
if (sel == MI_DURATION && _cursor < _len) {
|
||||
uint8_t p = notePitch(_notes[_cursor]);
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
// north at the top): a filled dot is a satellite the fix uses, a
|
||||
// hollow one is tracked but unused, a single pixel is in view with
|
||||
// no signal. Beside it: fix, used / in view, HDOP, PDOP and the
|
||||
// time to first fix (or how long it's been looking).
|
||||
// time to first fix (or how long it's been looking), and on a
|
||||
// 40-character screen a signal bar a satellite under them.
|
||||
// Signal -- a bar per satellite, its height the signal (C/N0, 50 dB-Hz =
|
||||
// full), filled when used; a dotted line marks 30 dB-Hz (good).
|
||||
// The top line counts used / in view per constellation.
|
||||
@@ -45,16 +46,19 @@ namespace skyview {
|
||||
return n;
|
||||
}
|
||||
|
||||
inline void signalRows(DisplayDriver& d, GpsSky& g, int x0, int y, int x1);
|
||||
|
||||
inline void renderSky(DisplayDriver& d, GpsSky& g, int top) {
|
||||
const int lh = d.getLineHeight();
|
||||
const int cw = d.getCharWidth();
|
||||
const int s = miniIconScale(d);
|
||||
// A wide screen sets the numbers beside the plot, a tall one (portrait
|
||||
// e-ink) under it, the plot then as wide as the screen.
|
||||
const bool stacked = d.height() - top > d.width();
|
||||
const int r = stacked ? (d.width() - 1) / 2 - 1 : (d.height() - 1 - top) / 2 - 1;
|
||||
const int cx = stacked ? d.width() / 2 : r + 1;
|
||||
const int cy = top + (stacked ? lh / 2 : 0) + r + 1;
|
||||
// The marks grow with the plot, so a big one (the 4.2") doesn't lose them.
|
||||
const int s = miniIconScale(d) > r / 40 ? miniIconScale(d) : r / 40;
|
||||
|
||||
info::circle(d, cx, cy, r); // the horizon
|
||||
info::circle(d, cx, cy, r / 2, 2); // 45 degrees up, dotted
|
||||
@@ -80,6 +84,8 @@ namespace skyview {
|
||||
d.fillRect(x - s, y - s, 2 * s + 1, 2 * s + 1);
|
||||
d.setColor(DisplayDriver::LIGHT);
|
||||
d.drawRect(x - s, y - s, 2 * s + 1, 2 * s + 1);
|
||||
} else if (s > 1) { // in view, no signal: a small point
|
||||
d.fillRect(x - 1, y - 1, 3, 3);
|
||||
} else {
|
||||
dot(d, x, y);
|
||||
}
|
||||
@@ -113,6 +119,45 @@ namespace skyview {
|
||||
}
|
||||
else buf[0] = 0;
|
||||
d.setCursor(x0, y); d.print(buf);
|
||||
// A screen 40 characters wide (the 4.2" e-ink) lists the strongest under
|
||||
// the numbers as well, a bar each.
|
||||
y += step + lh / 2;
|
||||
if (!stacked && d.width() >= 40 * cw && d.height() - y >= 4 * lh)
|
||||
signalRows(d, g, x0, y, d.width() - 2);
|
||||
}
|
||||
|
||||
// Satellites in order: used first, then the strongest. Returns the count.
|
||||
inline int sorted(const GpsSky& g, int* idx) {
|
||||
int n = g.count;
|
||||
for (int i = 0; i < n; i++) idx[i] = i;
|
||||
auto key = [&](int i) { return (g.used(g.sats[i]) ? 100 : 0) + g.sats[i].snr; };
|
||||
for (int i = 1; i < n; i++) { // insertion sort, <= 64
|
||||
int v = idx[i], j = i - 1;
|
||||
while (j >= 0 && key(idx[j]) < key(v)) { idx[j + 1] = idx[j]; j--; }
|
||||
idx[j + 1] = v;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// A row a satellite from x0 / y to x1, as many as fit above the bottom:
|
||||
// its system letter and number, then a bar as long as its signal (50
|
||||
// dB-Hz = full), filled when the fix uses it.
|
||||
inline void signalRows(DisplayDriver& d, GpsSky& g, int x0, int y, int x1) {
|
||||
const int lh = d.getLineHeight(), step = lh + 1;
|
||||
int idx[GpsSky::MAX_SATS];
|
||||
const int n = sorted(g, idx);
|
||||
const int bx = x0 + d.getTextWidth("G000") + 4, bw = x1 - bx, bh = lh - 6;
|
||||
char buf[8];
|
||||
for (int k = 0; k < n && y + lh <= d.height(); k++, y += step) {
|
||||
const GpsSky::Sat& sat = g.sats[idx[k]];
|
||||
snprintf(buf, sizeof(buf), "%c%d", GpsSky::sysLetter(sat.sys), sat.prn);
|
||||
d.setCursor(x0, y); d.print(buf);
|
||||
const int by = y + 3, snr = sat.snr > 50 ? 50 : sat.snr;
|
||||
const int len = snr <= 0 ? 0 : (bw * snr / 50 > 2 ? bw * snr / 50 : 2);
|
||||
if (!len) dot(d, bx, by + bh / 2);
|
||||
else if (g.used(sat)) d.fillRect(bx, by, len, bh);
|
||||
else d.drawRect(bx, by, len, bh);
|
||||
}
|
||||
}
|
||||
|
||||
inline void renderSignal(DisplayDriver& d, GpsSky& g, int top) {
|
||||
@@ -135,19 +180,13 @@ namespace skyview {
|
||||
|
||||
// Used first, then the strongest.
|
||||
int idx[GpsSky::MAX_SATS];
|
||||
int n = g.count;
|
||||
for (int i = 0; i < n; i++) idx[i] = i;
|
||||
auto key = [&](int i) { return (g.used(g.sats[i]) ? 100 : 0) + g.sats[i].snr; };
|
||||
for (int i = 1; i < n; i++) { // insertion sort, <= 64
|
||||
int v = idx[i], j = i - 1;
|
||||
while (j >= 0 && key(idx[j]) < key(v)) { idx[j + 1] = idx[j]; j--; }
|
||||
idx[j + 1] = v;
|
||||
}
|
||||
int n = sorted(g, idx);
|
||||
int fit = (W + 1) / 3; // bars at least 2 px wide, 1 px apart
|
||||
if (n > fit) n = fit;
|
||||
if (!n) return;
|
||||
int bw = (W + 1) / n - 1;
|
||||
if (bw > 8) bw = 8;
|
||||
const int cap = W >= 40 * d.getCharWidth() ? W / 16 : 8; // wider on the 4.2"
|
||||
if (bw > cap) bw = cap;
|
||||
|
||||
const int y0 = top + lh + 3, y1 = d.height() - 1, H = y1 - y0;
|
||||
for (int x = 0; x < W; x += 3) dot(d, x, y1 - H * 30 / 50); // 30 dB-Hz
|
||||
|
||||
@@ -52,7 +52,9 @@ private:
|
||||
// E-ink draws them as charts (chartBlock()) over a longer span: a sample
|
||||
// every 5 minutes, 8 hours. Its Radio chart also has the SNR of the last
|
||||
// 32 packets.
|
||||
public:
|
||||
static const int HIST_N = Features::IS_EINK ? 96 : 32;
|
||||
private:
|
||||
static constexpr const char* HIST_SPAN = Features::IS_EINK ? "8 h ago" : "32 min";
|
||||
uicore::StatusHistory<HIST_N, Features::IS_EINK ? 300000UL : 60000UL> _hist;
|
||||
|
||||
@@ -60,16 +62,18 @@ private:
|
||||
// chart under it.
|
||||
template <int N>
|
||||
void chartBlock(DisplayDriver& d, info::Flow& f, int rsv, const char* title, const char* now,
|
||||
const info::History<N>& hs, int min_span, bool bars, const char* span) {
|
||||
const info::History<N>& hs, int min_span, bool bars, const char* span,
|
||||
info::ChartAxisFmt axis) {
|
||||
const int lh = d.getLineHeight(), ch = 3 * lh;
|
||||
if (!f.place(d.lineStep() + info::chartH(d, ch) + 2)) return;
|
||||
info::valueRow(d, f.at, title, now, false, rsv, 1);
|
||||
info::chart(d, 1, f.at + d.lineStep(), d.width() - rsv - 3, ch, hs, min_span, bars, span, "now");
|
||||
info::chart(d, 1, f.at + d.lineStep(), d.width() - rsv - 3, ch, hs, min_span, bars, span, "now", axis);
|
||||
}
|
||||
|
||||
void addTab(uint8_t t, const char* label) { _tabs[_count] = t; _labels[_count] = label; _count++; }
|
||||
uint8_t tab() const { return _tabs[_cur]; }
|
||||
|
||||
public:
|
||||
static uint32_t totalRx() {
|
||||
uint32_t n = 0;
|
||||
for (uint8_t t = 0; t < 16; t++) n += the_mesh.getNumRecvByType(t);
|
||||
@@ -80,6 +84,7 @@ private:
|
||||
for (uint8_t t = 0; t < 16; t++) n += the_mesh.getNumSentByType(t);
|
||||
return n;
|
||||
}
|
||||
private:
|
||||
|
||||
int battMv() const { return _task->getBattMilliVolts(); }
|
||||
int battPct() const {
|
||||
@@ -120,7 +125,7 @@ private:
|
||||
const int nf = (int)radio_driver.getNoiseFloor();
|
||||
if (nf) snprintf(a, sizeof(a), "%d dBm", nf); else strcpy(a, "-");
|
||||
if (Features::IS_EINK) {
|
||||
chartBlock(d, f, rsv, "Noise", a, _hist.noise, 6, false, HIST_SPAN);
|
||||
chartBlock(d, f, rsv, "Noise", a, _hist.noise, 6, false, HIST_SPAN, info::axisPlain);
|
||||
} else if (f.place(d.lineStep())) { // label, the last half hour, the value now
|
||||
info::valueRow(d, f.at, "Noise", a, false, rsv, 1);
|
||||
const int x0 = 1 + d.getTextWidth("Noise") + 6;
|
||||
@@ -134,7 +139,7 @@ private:
|
||||
drawSignalBars(d, d.width() - rsv - 2 - d.getTextWidth(b) - 5, f.at, (int)(snr * 4));
|
||||
}
|
||||
snprintf(b, sizeof(b), "%.1f dB", radio_driver.getLastSNR());
|
||||
if (Features::IS_EINK) chartBlock(d, f, rsv, "SNR", b, _hist.snr, 20, false, "32 pkts");
|
||||
if (Features::IS_EINK) chartBlock(d, f, rsv, "SNR", b, _hist.snr, 20, false, "32 pkts", info::axisQuarterDb);
|
||||
else row(d, f, rsv, "Last SNR", b);
|
||||
}
|
||||
|
||||
@@ -210,7 +215,7 @@ private:
|
||||
row(d, f, rsv, "Battery", a);
|
||||
row(d, f, rsv, "Source", board.isExternalPowered() ? "USB" : "Battery");
|
||||
if (Features::IS_EINK) {
|
||||
chartBlock(d, f, rsv, "Trend", nullptr, _hist.batt, 20, false, HIST_SPAN);
|
||||
chartBlock(d, f, rsv, "Trend", nullptr, _hist.batt, 20, false, HIST_SPAN, info::axisVolts);
|
||||
} else if (f.place(d.lineStep())) {
|
||||
d.setCursor(1, f.at);
|
||||
d.print("Trend");
|
||||
@@ -255,7 +260,7 @@ private:
|
||||
if (Features::IS_EINK) { // packets per 5 minutes, as bars
|
||||
const int ch = 3 * lh;
|
||||
if (f.place(info::chartH(d, ch) + 2))
|
||||
info::chart(d, 1, f.at, d.width() - rsv - 3, ch, _hist.traffic, 4, true, HIST_SPAN, "now");
|
||||
info::chart(d, 1, f.at, d.width() - rsv - 3, ch, _hist.traffic, 4, true, HIST_SPAN, "now", info::axisPlain);
|
||||
} else if (f.place(lh + 6)) {
|
||||
info::spark(d, 1, f.at, d.width() - rsv - 3, lh + 3, _hist.traffic, 4);
|
||||
}
|
||||
@@ -352,6 +357,8 @@ public:
|
||||
}
|
||||
|
||||
const info::History<HIST_N>& battHistory() const { return _hist.batt; }
|
||||
const info::History<HIST_N>& noiseHistory() const { return _hist.noise; }
|
||||
const info::History<HIST_N>& trafficHistory() const { return _hist.traffic; }
|
||||
|
||||
// Contacts heard from in the last hour (cached for 10 s: it walks them all).
|
||||
static int heardLastHour() {
|
||||
|
||||
@@ -232,21 +232,6 @@ static BigClock bigClockSize(const DisplayDriver& d) {
|
||||
if (sc < 1) sc = 1;
|
||||
return { sc, lettering::LOGO_H * sc };
|
||||
}
|
||||
static void drawLettering(DisplayDriver& d, int x, int y, const char* t, int sc, int gap) {
|
||||
for (const char* p = t; *p; p++) {
|
||||
const int cw = lettering::charW(*p);
|
||||
if (cw < 0) continue;
|
||||
for (int r = 0; r < lettering::LOGO_H; r++)
|
||||
for (int col = 0; col < cw; col++) {
|
||||
if (!lettering::inked(*p, col, r)) continue;
|
||||
int e = col;
|
||||
while (e + 1 < cw && lettering::inked(*p, e + 1, r)) e++;
|
||||
d.fillRect(x + col * sc, y + r * sc, (e - col + 1) * sc, sc);
|
||||
col = e;
|
||||
}
|
||||
x += cw * sc + gap;
|
||||
}
|
||||
}
|
||||
// The big clock's text, gaps and AM / PM, measured: its full width (w), the
|
||||
// digits' (dw). draw() puts its left edge at x.
|
||||
struct BigClockText {
|
||||
@@ -267,7 +252,7 @@ struct BigClockText {
|
||||
w = dw + (ap ? (int)d.getTextWidth(ap) + 2 * sc : 0);
|
||||
}
|
||||
void draw(DisplayDriver& d, int x, int y) const {
|
||||
drawLettering(d, x, y, t, sc, gap);
|
||||
info::lettering(d, x, y, t, sc, gap);
|
||||
if (!ap) return;
|
||||
d.setCursor(x + dw + 2 * sc, y + lettering::LOGO_H * sc - d.getLineHeight() + 1);
|
||||
d.print(ap);
|
||||
@@ -989,8 +974,12 @@ public:
|
||||
const int W = d.width(), H = d.height(), lh = d.getLineHeight();
|
||||
const int s = miniIconScale(d);
|
||||
const bool tall = H - top > W;
|
||||
// A screen 40 characters wide (the 4.2"): a square map and a column of
|
||||
// labelled rows beside it, one line apart.
|
||||
const bool wide = !tall && W >= 40 * d.getCharWidth();
|
||||
const int rows_h = tall ? 4 * (lh + lh / 2) : 0;
|
||||
const int pw = tall ? W : W / 2, ph = H - top - rows_h; // map panel
|
||||
const int ph = H - top - rows_h;
|
||||
const int pw = tall ? W : wide ? (ph < W * 3 / 5 ? ph : W * 3 / 5) : W / 2; // map panel
|
||||
d.setColor(DisplayDriver::LIGHT);
|
||||
d.drawSoftRect(0, top, pw, ph);
|
||||
if (!drawMapPreview(d, 2, top + 2, pw - 4, ph - 4)) {
|
||||
@@ -1006,6 +995,8 @@ public:
|
||||
d.print(why);
|
||||
}
|
||||
|
||||
if (wide) { drawMapColumn(d, pw + 6, top, W - 2); return; }
|
||||
|
||||
// The column: four rows, always, like the Status tiles.
|
||||
const int cx = tall ? 2 : pw + 3, cw = W - cx;
|
||||
const int row_h = tall ? rows_h / 4 : ph / 4;
|
||||
@@ -1049,6 +1040,72 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// The wide Map page's column, x0..x1 from top: GPS and the position, the
|
||||
// trail, then live contacts, the target and waypoints, each an icon, a
|
||||
// label and the value at the right, the groups split by a dotted rule.
|
||||
void drawMapColumn(DisplayDriver& d, int x0, int top, int x1) {
|
||||
const int lh = d.getLineHeight(), s = miniIconScale(d), step = d.lineStep() + 2;
|
||||
const int lx = x0 + PAGE_ICON_PX * s + 2 * s; // labels clear of the widest icon
|
||||
int y = top + 1;
|
||||
auto row = [&](const MiniIcon* ic, const char* label, const char* val) {
|
||||
if (ic) miniIconDraw(d, x0, y + (lh - ic->h * s) / 2 - s, *ic);
|
||||
d.setCursor(lx, y); d.print(label);
|
||||
d.drawTextRightAlign(x1, y, val);
|
||||
y += step;
|
||||
};
|
||||
auto rule = [&]() { info::rule(d, x0, y + lh / 2 - 2, x1 - x0); y += lh / 2 + 2; };
|
||||
char buf[24];
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
LocationProvider* loc = sensors.getLocationProvider();
|
||||
const bool on = _task->getGPSState();
|
||||
if (!loc) strcpy(buf, "none");
|
||||
else if (!on) strcpy(buf, "off");
|
||||
else if (!loc->isValid()) strcpy(buf, "no fix");
|
||||
else snprintf(buf, sizeof(buf), "%ld sats", loc->satellitesCount());
|
||||
row(&ICON_GPS, "GPS", buf);
|
||||
int32_t mla, mlo;
|
||||
if (_task->currentLocation(mla, mlo)) {
|
||||
snprintf(buf, sizeof(buf), "%.5f", mla / 1e6); row(nullptr, "Lat", buf);
|
||||
snprintf(buf, sizeof(buf), "%.5f", mlo / 1e6); row(nullptr, "Lon", buf);
|
||||
}
|
||||
#else
|
||||
row(&ICON_GPS, "GPS", "none");
|
||||
#endif
|
||||
rule();
|
||||
TrailStore& tr = _task->trail();
|
||||
if (tr.empty() && !tr.isActive()) strcpy(buf, "-");
|
||||
else geo::fmtDist(buf, sizeof(buf), tr.totalDistanceMeters() / 1000.0f, _task->useImperial(), true);
|
||||
row(&ICON_TRAIL, "Trail", buf);
|
||||
if (!tr.empty() || tr.isActive()) {
|
||||
const uint32_t t = tr.elapsedSeconds();
|
||||
snprintf(buf, sizeof(buf), "%luh %02lum", (unsigned long)(t / 3600), (unsigned long)(t / 60 % 60));
|
||||
row(nullptr, tr.isActive() ? (tr.isPaused() ? "Paused" : "Rec") : "Time", buf);
|
||||
snprintf(buf, sizeof(buf), "%d", tr.count());
|
||||
row(nullptr, "Points", buf);
|
||||
}
|
||||
rule();
|
||||
const float km = statusDistanceKm();
|
||||
int32_t tla, tlo;
|
||||
const bool target = _task->activeTargetPos(tla, tlo);
|
||||
const int live = _task->liveTrack().active(rtc_clock.getCurrentTime());
|
||||
snprintf(buf, sizeof(buf), "%d", live);
|
||||
row(&ICON_MAP_CONTACT, "Live", buf);
|
||||
if (live > 0 && km >= 0 && !target) {
|
||||
geo::fmtDist(buf, sizeof(buf), km, _task->useImperial(), true);
|
||||
row(nullptr, "Nearest", buf);
|
||||
}
|
||||
if (target) {
|
||||
if (km >= 0) geo::fmtDist(buf, sizeof(buf), km, _task->useImperial(), true);
|
||||
else strcpy(buf, "-");
|
||||
row(&ICON_MAP_TARGET, "Target", buf);
|
||||
}
|
||||
snprintf(buf, sizeof(buf), "%d", _task->waypoints().count());
|
||||
row(&ICON_MAP_WAYPOINT, "Waypoints", buf);
|
||||
}
|
||||
|
||||
// The Favourites grid: landscape 3x2, portrait 2x3.
|
||||
static int favCols(DisplayDriver& d) { return d.isLandscape() ? 3 : 2; }
|
||||
|
||||
// The clock fields set in Settings (dashboard_fields), in order; their count.
|
||||
int clockFields(uint8_t* out) {
|
||||
int n = 0;
|
||||
@@ -1090,14 +1147,21 @@ public:
|
||||
uint8_t fields[3];
|
||||
const int nf = clockFields(fields);
|
||||
|
||||
// The month under the date, where it fits: a tall screen (portrait
|
||||
// e-ink), or the 4.2" landscape one with the clock moved to the top.
|
||||
const int fields_h = nf ? 2 * lh + 1 + lh / 2 : 0;
|
||||
const int cal_h = calendar::height(d, ti);
|
||||
bool cal = false;
|
||||
int date_y;
|
||||
if (d.height() > d.width()) { // portrait e-ink: stacked digits
|
||||
date_y = drawClockTall(d, lh / 2, &ti, h12);
|
||||
cal = true;
|
||||
} else {
|
||||
const BigClock bc = bigClockSize(d);
|
||||
const int bar = 3 * bc.sc / 2; // the seconds bar and its gaps
|
||||
const int block = bc.h + bar + 1 + lh;
|
||||
const int y = nf ? bc.sc : (H - block) / 2;
|
||||
cal = bc.sc + block + lh / 2 + 2 + cal_h <= H - fields_h;
|
||||
const int y = nf || cal ? bc.sc : (H - block) / 2;
|
||||
drawBigClock(d, W / 2, y, &ti, h12, bc.sc);
|
||||
const int bar_y = y + bc.h + bar / 2 + 1;
|
||||
if (show_sec) { // fills over the minute: dotted track, solid part
|
||||
@@ -1114,11 +1178,11 @@ public:
|
||||
snprintf(buf, sizeof(buf), "%s %d %s %d", WDAY[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon], 1900 + ti.tm_year);
|
||||
d.drawTextCentered(W / 2, date_y, buf);
|
||||
|
||||
// A tall screen (portrait e-ink): the month under the date, in the room
|
||||
// above the clock fields.
|
||||
const int cal_top = date_y + lh + lh / 2 + 2;
|
||||
const int cal_bottom = nf ? H - 2 * lh - 1 - lh / 2 : H;
|
||||
if (H > W && calendar::height(d, ti) <= cal_bottom - cal_top) calendar::draw(d, 0, cal_top, W, ti, alarmDays(ti));
|
||||
if (cal && cal_h <= H - fields_h - cal_top) { // no wider than four characters a day
|
||||
const int cw = W < 28 * d.getCharWidth() ? W : 28 * d.getCharWidth();
|
||||
calendar::draw(d, (W - cw) / 2, cal_top, cw, ti, alarmDays(ti));
|
||||
}
|
||||
|
||||
if (nf == 0) return;
|
||||
refresh_sensors();
|
||||
@@ -1158,6 +1222,9 @@ public:
|
||||
if (synced) localTm(unix_ts, _node_prefs ? _node_prefs->tz_offset_hours : 0, ti);
|
||||
const bool h12 = _node_prefs && _node_prefs->clock_12h;
|
||||
const bool compact = _node_prefs && _node_prefs->lock_compact && W > H;
|
||||
// The month and the rows under the big clock: on a tall screen (portrait
|
||||
// e-ink), and on one 40 characters wide (the 4.2").
|
||||
const bool extras = H > W || W >= 40 * d.getCharWidth();
|
||||
|
||||
char pill[24] = "";
|
||||
if (compact) {
|
||||
@@ -1205,9 +1272,9 @@ public:
|
||||
char buf[16];
|
||||
snprintf(buf, sizeof(buf), "%s %d %s", WDAY[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon]);
|
||||
d.drawTextCentered(W / 2, date_y, buf);
|
||||
if (H > W) drawLockTallExtras(d, date_y + lh + lh / 2 + 2, hint ? pill_y - 3 : H, ti, unread);
|
||||
if (extras) drawLockTallExtras(d, date_y + lh + lh / 2 + 2, hint ? pill_y - 3 : H, ti, unread);
|
||||
}
|
||||
if (!hint && unread > 0 && !(synced && H > W)) snprintf(pill, sizeof(pill), "%d%s new", unread, _task->getAnyUnreadOverflow() ? "+" : "");
|
||||
if (!hint && unread > 0 && !(synced && extras)) snprintf(pill, sizeof(pill), "%d%s new", unread, _task->getAnyUnreadOverflow() ? "+" : "");
|
||||
}
|
||||
|
||||
if (hint) {
|
||||
@@ -1227,7 +1294,7 @@ public:
|
||||
d.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
// Under a tall lock clock (portrait e-ink), between y and bottom: the month,
|
||||
// Under the lock clock on a tall or a 4.2" screen, between y and bottom: the month,
|
||||
// then a row each for unread messages, the armed alarm and the clock fields
|
||||
// -- the rows first, the calendar only if there's room for it as well.
|
||||
void drawLockTallExtras(DisplayDriver& d, int y, int bottom, const struct tm& ti, int unread) {
|
||||
@@ -1251,7 +1318,8 @@ public:
|
||||
}
|
||||
const int rows_h = n * step;
|
||||
if (calendar::height(d, ti) + d.getLineHeight() / 2 + rows_h <= bottom - y) {
|
||||
calendar::draw(d, 0, y, W, ti, alarmDays(ti));
|
||||
const int cw = W < 28 * d.getCharWidth() ? W : 28 * d.getCharWidth(); // as on the Clock page
|
||||
calendar::draw(d, (W - cw) / 2, y, cw, ti, alarmDays(ti));
|
||||
y += calendar::height(d, ti) + d.getLineHeight() / 2;
|
||||
}
|
||||
for (int k = 0; k < n && y + d.getLineHeight() <= bottom; k++, y += step) {
|
||||
@@ -1262,30 +1330,51 @@ public:
|
||||
}
|
||||
|
||||
// The Status page: four tiles split by dotted rules, each one subject's
|
||||
// headline (icon + main value) over a detail line. Enter opens the Status
|
||||
// screen with all of it.
|
||||
// headline (icon + main value) over a detail line. A tile with the room
|
||||
// (the 4.2" panel) adds a line and a chart of its history under it. Enter
|
||||
// opens the Status screen with all of it.
|
||||
void drawStatusTiles(DisplayDriver& d, int top) {
|
||||
const int W = d.width(), H = d.height() - top;
|
||||
const int lh = d.getLineHeight(), s = miniIconScale(d);
|
||||
const int mid_x = W / 2, mid_y = top + H / 2;
|
||||
const bool roomy = H / 2 >= 7 * lh; // three lines and a chart a tile
|
||||
StatusScreen* st = (StatusScreen*)_task->statusScreen();
|
||||
d.setColor(DisplayDriver::LIGHT);
|
||||
info::vrule(d, mid_x, top, H);
|
||||
info::rule(d, 0, mid_y, W);
|
||||
auto tile = [&](int col, int row, const MiniIcon* ic, float batt, const char* head, const char* detail) {
|
||||
const int x = col ? mid_x + 3 : 1, y = row ? mid_y + 2 : top + 1;
|
||||
const int w = (col ? W - x : mid_x - 2 - x);
|
||||
// The tile's box, inside the rules.
|
||||
auto box = [&](int col, int row, int& x, int& y, int& w, int& h) {
|
||||
x = col ? mid_x + 3 : 1; y = row ? mid_y + 2 : top + 1;
|
||||
w = col ? W - x : mid_x - 2 - x;
|
||||
h = (row ? top + H : mid_y - 1) - y;
|
||||
};
|
||||
auto tile = [&](int col, int row, const MiniIcon* ic, float batt, const char* head, const char* detail,
|
||||
const char* more = "") {
|
||||
int x, y, w, h;
|
||||
box(col, row, x, y, w, h);
|
||||
const int iy = y + (lh - info::batteryH(d)) / 2 - s;
|
||||
int tx = x;
|
||||
if (ic) { miniIconDraw(d, x, iy, *ic); tx = x + ic->w * s + 2 * s; }
|
||||
else if (batt >= 0) { info::battery(d, x, iy, batt); tx = x + info::batteryW(d) + 2 * s; }
|
||||
d.drawTextEllipsized(tx, y, x + w - tx, head);
|
||||
d.drawTextEllipsized(x, y + lh + 2, w, detail);
|
||||
if (roomy && more[0]) d.drawTextEllipsized(x, y + 2 * (lh + 2), w, more);
|
||||
};
|
||||
char h[20], t[20];
|
||||
// Under a roomy tile's three lines: a history.
|
||||
auto tileChart = [&](int col, int row, const info::History<StatusScreen::HIST_N>& hs, int min_span, bool bars) {
|
||||
int x, y, w, h;
|
||||
box(col, row, x, y, w, h);
|
||||
const int cy = y + 3 * (lh + 2) + 2, ch = y + h - cy - 3;
|
||||
if (ch >= 2 * lh) info::chart(d, x, cy, w - 4, ch, hs, min_span, bars, nullptr, nullptr);
|
||||
};
|
||||
char h[20], t[20], m[28];
|
||||
|
||||
snprintf(h, sizeof(h), "%.3f", _node_prefs->freq);
|
||||
snprintf(t, sizeof(t), "SF%u %gk", (unsigned)_node_prefs->sf, _node_prefs->bw);
|
||||
tile(0, 0, &ICON_PG_RADIO, -1, h, t);
|
||||
const int nf = (int)radio_driver.getNoiseFloor();
|
||||
if (nf) snprintf(m, sizeof(m), "Noise %d dBm", nf); else m[0] = 0;
|
||||
tile(0, 0, &ICON_PG_RADIO, -1, h, t, m);
|
||||
if (roomy && st) tileChart(0, 0, st->noiseHistory(), 6, false);
|
||||
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
{
|
||||
@@ -1304,7 +1393,10 @@ public:
|
||||
#endif
|
||||
}
|
||||
if (loc && on) snprintf(t, sizeof(t), "%ld sats", loc->satellitesCount());
|
||||
tile(1, 0, &ICON_GPS, -1, h, t);
|
||||
int32_t la, lo;
|
||||
m[0] = 0;
|
||||
if (loc && on && loc->isValid() && _task->currentLocation(la, lo)) snprintf(m, sizeof(m), "%.4f %.4f", la / 1e6, lo / 1e6);
|
||||
tile(1, 0, &ICON_GPS, -1, h, t, m);
|
||||
}
|
||||
#else
|
||||
tile(1, 0, &ICON_GPS, -1, "No GPS", "");
|
||||
@@ -1315,14 +1407,18 @@ public:
|
||||
snprintf(h, sizeof(h), "%d%%", pct);
|
||||
snprintf(t, sizeof(t), "%d.%02d V%s", mv / 1000, (mv % 1000) / 10, board.isExternalPowered() ? " USB" : "");
|
||||
tile(0, 1, nullptr, pct / 100.0f, h, t);
|
||||
if (StatusScreen* st = (StatusScreen*)_task->statusScreen()) { // the battery's trend beside its %
|
||||
if (st && roomy) tileChart(0, 1, st->battHistory(), 20, false);
|
||||
else if (st) { // the battery's trend beside its %
|
||||
const int x0 = 1 + info::batteryW(d) + 2 * s + d.getTextWidth(h) + 3 * s, x1 = mid_x - 3;
|
||||
if (x1 - x0 >= 12) info::spark(d, x0, mid_y + 2, x1 - x0, lh - 1, st->battHistory(), 20);
|
||||
}
|
||||
|
||||
snprintf(h, sizeof(h), "%d nodes", the_mesh.getNumContacts());
|
||||
const int nc = the_mesh.getNumContacts();
|
||||
snprintf(h, sizeof(h), "%d node%s", nc, nc == 1 ? "" : "s");
|
||||
snprintf(t, sizeof(t), "%d in 1 h", StatusScreen::heardLastHour());
|
||||
tile(1, 1, &ICON_MAP_CONTACT, -1, h, t);
|
||||
snprintf(m, sizeof(m), "RX %lu TX %lu", (unsigned long)StatusScreen::totalRx(), (unsigned long)StatusScreen::totalTx());
|
||||
tile(1, 1, &ICON_MAP_CONTACT, -1, h, t, m);
|
||||
if (roomy && st) tileChart(1, 1, st->trafficHistory(), 4, true);
|
||||
}
|
||||
|
||||
// Small 5x5 glyph shown in the page-indicator row for each HomePage.
|
||||
@@ -1378,6 +1474,9 @@ public:
|
||||
int hh = lt.tm_hour;
|
||||
if (_node_prefs->clock_12h) { hh %= 12; if (hh == 0) hh = 12; }
|
||||
snprintf(filtered_name, sizeof(filtered_name), "%d:%02d", hh, lt.tm_min);
|
||||
if (display.width() >= 40 * display.getCharWidth()) // the 4.2": the date beside it
|
||||
snprintf(filtered_name + strlen(filtered_name), sizeof(filtered_name) - strlen(filtered_name),
|
||||
" %s %d %s", WDAY[lt.tm_wday], lt.tm_mday, MON[lt.tm_mon]);
|
||||
} else {
|
||||
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
|
||||
}
|
||||
@@ -1473,7 +1572,7 @@ public:
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(1);
|
||||
|
||||
const int cols = display.isLandscape() ? 3 : 2;
|
||||
const int cols = favCols(display);
|
||||
const int rows = NodePrefs::FAVOURITES_COUNT / cols;
|
||||
const int grid_y = content_y + 4; // the gap under the page icons, as on every page
|
||||
const int grid_h = display.height() - grid_y;
|
||||
@@ -1508,6 +1607,8 @@ public:
|
||||
bool resolved = false;
|
||||
uint32_t last_ts = 0; // heard from / last post, for a tall card
|
||||
int32_t plat = 0, plon = 0; // a contact's advertised position
|
||||
const char* last_text = nullptr; // the newest message, for a big card
|
||||
bool last_out = false;
|
||||
|
||||
if (prefix && _task->favouriteSlotKind(i) == NodePrefs::FAV_KIND_CHANNEL) {
|
||||
uint8_t ch_idx = prefix[0];
|
||||
@@ -1523,7 +1624,7 @@ public:
|
||||
MessageHistory& h = _task->core().history;
|
||||
for (int j = 0; j < h.chHistCount(); j++) {
|
||||
const ChHistEntry& e = h.chAtPos(h.chHistPosNewest(j));
|
||||
if (e.ch_idx == ch_idx) { last_ts = e.timestamp; break; }
|
||||
if (e.ch_idx == ch_idx) { last_ts = e.timestamp; last_text = e.text; break; }
|
||||
}
|
||||
}
|
||||
} else if (prefix) {
|
||||
@@ -1537,6 +1638,9 @@ public:
|
||||
resolved = true;
|
||||
last_ts = c.lastmod;
|
||||
plat = c.gps_lat; plon = c.gps_lon;
|
||||
MessageHistory& h = _task->core().history;
|
||||
const int rp = h.dmHistEntryForContact(c.id.pub_key, 0);
|
||||
if (rp >= 0) { last_text = h.dmAtPos(rp).text; last_out = h.dmAtPos(rp).outgoing; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1566,7 +1670,41 @@ public:
|
||||
if (sel && r > 0) mq_delay = r;
|
||||
if (unread > 0)
|
||||
display.drawUnreadBadge(tx + tw - 3, name_y, unread, sel, overflow);
|
||||
if (tall) {
|
||||
// A card six lines tall on a screen 40 characters wide (the 4.2"):
|
||||
// the newest message under the name, how long ago and how far along
|
||||
// the bottom.
|
||||
if (th >= 6 * line_h && display.width() >= 40 * display.getCharWidth()) {
|
||||
display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT);
|
||||
const int by = ty + th - line_h - 3; // the bottom line
|
||||
const uint32_t now = rtc_clock.getCurrentTime();
|
||||
char buf[16];
|
||||
if (last_ts) {
|
||||
geo::fmtAgeShort(buf, sizeof(buf), now, last_ts);
|
||||
display.setCursor(tx + 3, by); display.print(buf);
|
||||
}
|
||||
int32_t mla, mlo;
|
||||
if ((plat || plon) && _task->currentLocation(mla, mlo)) {
|
||||
geo::fmtDist(buf, sizeof(buf), geo::haversineKm(mla, mlo, plat, plon), _task->useImperial(), true);
|
||||
display.drawTextRightAlign(tx + tw - 3, by, buf);
|
||||
}
|
||||
const int my = name_y + line_h + line_h / 3; // the message, wrapped
|
||||
const int nl_max = (by - 2 - my) / line_h;
|
||||
if (nl_max > 0) {
|
||||
char pv[MSG_TEXT_BUF + 4], tr_pv[MSG_TEXT_BUF + 4];
|
||||
if (last_text) snprintf(pv, sizeof(pv), "%s%s", last_out ? "Me: " : "", last_text);
|
||||
else snprintf(pv, sizeof(pv), "No messages");
|
||||
display.translateUTF8ToBlocks(tr_pv, pv, sizeof(tr_pv));
|
||||
char lines[5][FS_CHARS_MAX];
|
||||
const int cap = nl_max < 4 ? nl_max : 4;
|
||||
int n = FullscreenMsgView::wrapLines(display, tr_pv, tw - 6, lines, cap + 1);
|
||||
if (n > cap) { // more than fits: the last line shown ends in an ellipsis
|
||||
n = cap;
|
||||
const size_t l = strlen(lines[n - 1]);
|
||||
if (l + 4 < sizeof(lines[0])) strcat(lines[n - 1], " ...");
|
||||
}
|
||||
for (int k = 0; k < n; k++) display.drawTextEllipsized(tx + 3, my + k * line_h, tw - 6, lines[k]);
|
||||
}
|
||||
} else if (tall) {
|
||||
display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); // the badge leaves its own
|
||||
const int s = miniIconScale(display), gap = line_h / 3;
|
||||
int y = name_y + line_h + gap;
|
||||
@@ -1645,7 +1783,7 @@ public:
|
||||
return true;
|
||||
}
|
||||
DisplayDriver* d = _task->getDisplay();
|
||||
const int cols = (d && d->isLandscape()) ? 3 : 2;
|
||||
const int cols = d ? favCols(*d) : 2;
|
||||
const int rows = NodePrefs::FAVOURITES_COUNT / cols;
|
||||
int col = _fav_sel % cols;
|
||||
int row = _fav_sel / cols;
|
||||
|
||||
Reference in New Issue
Block a user