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:
Jakub
2026-10-06 13:44:11 +02:00
co-authored by Claude Opus 5.5
parent cea102c152
commit 3326881dc1
8 changed files with 442 additions and 108 deletions
+179 -41
View File
@@ -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;