Files
MeshCore-Solo/examples/companion_radio/ui-new/HomeScreen.h
T
JakubandClaude Opus 5.5 e896e6af60 refactor(ui-new): Home, splash, CardKB and the perf walk out of UITask.cpp
A pure move: each block now sits in its own header, included where its
code was, so the order of definitions is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:26:58 +02:00

1760 lines
82 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#pragma once
// Included into UITask.cpp only (see the note on screen fragments there).
// Home: the clock, favourites, status, Bluetooth ... pages, and the lock screen.
// The time on a tall portrait panel (e-ink in portrait -- height > width): HH
// and MM stacked on two lines in the huge built-in font (size 4, ~56 px tall)
// so the digits fill the narrow width. Returns the y just below it.
static int drawClockTall(DisplayDriver& d, int top_y, const struct tm* ti, bool h12) {
int hh = ti->tm_hour;
const char* ap = nullptr;
if (h12) { ap = (hh < 12) ? "AM" : "PM"; hh %= 12; if (hh == 0) hh = 12; }
const int cx = d.width() / 2;
char hbuf[4], mbuf[4];
snprintf(hbuf, sizeof(hbuf), "%02d", hh);
snprintf(mbuf, sizeof(mbuf), "%02d", ti->tm_min);
int y = top_y;
d.setTextSize(4);
const int lhb = d.getLineHeight();
// The built-in GFX font advances 6 px per char but the glyph is only 5 px
// wide, so getTextWidth() over-reports by one trailing blank column and
// drawTextCentered() would bias the digits ~half a column to the left.
// Centre on the visible width (minus that trailing column) instead.
const int trail = d.getCharWidth() / 6; // one built-in column at this size
auto drawBig = [&](const char* s, int yy) {
d.setCursor(cx - ((int)d.getTextWidth(s) - trail) / 2, yy);
d.print(s);
};
drawBig(hbuf, y); y += lhb + 2;
drawBig(mbuf, y); y += lhb + 2;
if (ap) {
d.setTextSize(2);
d.drawTextCentered(cx, y, ap);
y += d.getLineHeight() + 1;
}
d.setTextSize(1);
return y;
}
// The big clock on wide panels (Home clock page, lock screen): HH:MM in the
// splash's slanted lettering (ui-core/Lettering.h) at `sc`, centred on cx. A
// 12-hour clock drops the leading zero, closes the gaps up and sets AM / PM
// beside the digits on their baseline -- "12:59 PM" still fits 128 px.
struct BigClock { int sc, h; };
static BigClock bigClockSize(const DisplayDriver& d) {
int sc = d.width() / 64;
if (sc > d.height() / 32) sc = d.height() / 32;
if (sc < 1) sc = 1;
return { sc, lettering::LOGO_H * sc };
}
// 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 {
char t[8];
const char* ap = nullptr;
int sc, gap, dw, w;
BigClockText(DisplayDriver& d, const struct tm* ti, bool h12, int scale) : sc(scale), gap(scale + 1) {
if (h12) {
int hh = ti->tm_hour % 12; if (hh == 0) hh = 12;
snprintf(t, sizeof(t), "%d:%02d", hh, ti->tm_min);
ap = ti->tm_hour < 12 ? "AM" : "PM";
gap = sc / 2;
} else {
snprintf(t, sizeof(t), "%02d:%02d", ti->tm_hour, ti->tm_min);
}
dw = lettering::textW(t, 0) * sc + gap * ((int)strlen(t) - 1);
d.setTextSize(1);
w = dw + (ap ? (int)d.getTextWidth(ap) + 2 * sc : 0);
}
void draw(DisplayDriver& d, int x, int y) const {
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);
}
};
static void drawBigClock(DisplayDriver& d, int cx, int y, const struct tm* ti, bool h12, int sc) {
BigClockText bt(d, ti, h12, sc);
bt.draw(d, cx - bt.w / 2, y);
}
static const char* const WDAY[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"};
static const char* const MON[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"};
// ── HomeScreen ────────────────────────────────────────────────────────────────
// A dashboard field in this display's font (ui-core/Telemetry.h); `nouns`
// when it stands without its label (the lock screen). "" for none.
static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_mv, uint16_t low_batt_mv,
int unread, bool unread_overflow, bool imperial, CayenneLPP* lpp, bool nouns) {
val[0] = '\0';
if (field == telemetry::NONE || field >= telemetry::COUNT) return;
telemetry::Style st = L1_TELEMETRY;
st.nouns = nouns;
if (nouns) st.gps_dp = 2; // no label: room for the position is shorter
telemetry::Inputs in;
in.batt_mv = batt_mv;
in.low_batt_mv = low_batt_mv;
in.nodes = the_mesh.getNumContacts();
in.unread = unread;
in.unread_more = unread_overflow;
in.imperial = imperial;
#if ENV_INCLUDE_GPS == 1
in.loc = sensors.getLocationProvider();
{ NodePrefs* np = the_mesh.getNodePrefs(); in.gps_on = np && np->gps_enabled; } // off: no stale satellite count
#endif
if (telemetry::isSensor(field)) {
if (!lpp) {
static CayenneLPP s_lpp(200); s_lpp.reset();
{ CardKBBusLock bus_lock; sensors.querySensors(0xFF, s_lpp); }
lpp = &s_lpp;
}
in.lpp = lpp->getBuffer();
in.lpp_len = lpp->getSize();
}
telemetry::text(field, in, st, val, val_len);
}
class HomeScreen : public UIScreen {
enum HomePage {
CLOCK,
FAVOURITES,
STATUS, // radio, GPS, power, mesh at a glance (took over Radio / GPS / Sensors)
BLUETOOTH,
ADVERT,
SETTINGS,
MAP,
TOOLS,
QUICK_MSG,
SHUTDOWN,
Count, // keep before LOCK — navigable-page count
LOCK // lock screen: full clock + dashboard view, not a navigable page
};
// Selected slot on the Favourites page (0..FAVOURITES_COUNT - 1).
uint8_t _fav_sel = 0;
// Slot payload (pubkey prefix, or channel index in byte 0 — see the slot's
// kind), or nullptr when the slot is empty.
const uint8_t* favSlotPrefix(int slot) const {
NodePrefs* p = _task->getNodePrefs();
if (!p || _task->isFavouriteSlotEmpty(slot)) return nullptr;
return p->favourite_contacts[slot];
}
// Unpin/Replace menu for a filled tile. Choosing what to pin is the Messages
// screen's job (see UITask::pickFavouriteTarget) -- the dial doesn't carry a
// second browser for contacts, rooms and channels.
PopupMenu _tile_menu;
int _pin_target_slot = -1;
UITask* _task;
mesh::RTCClock* _rtc;
SensorManager* _sensors;
NodePrefs* _node_prefs;
uint8_t _page;
uint8_t _prev_page; // home page restored when the device unlocks
bool _shutdown_init;
// Sideways slide between pages (OLED; drivers that can't read their frame
// back just cut over). _slide_dir is +1 (new page from the right), -1, or 0.
static const unsigned long SLIDE_MS = 220;
int8_t _slide_dir = 0;
uint8_t _slide_from = 0;
unsigned long _slide_t0 = 0;
// First row under the header and the page-icon row (see render()).
static int contentTop(DisplayDriver& d) {
const int pg_half = (PAGE_ICON_PX * statusIconScale(d) + 1) / 2;
return statusBarH(d) + 2 * pg_half + 4;
}
// 0..1 eased progress of the running slide, or -1 when none.
float slideProgress() {
if (!_slide_dir) return -1;
unsigned long el = millis() - _slide_t0;
if (el >= SLIDE_MS) { _slide_dir = 0; return -1; }
float t = 1.0f - (float)el / SLIDE_MS;
return 1.0f - t * t * t; // ease-out: quick start, soft landing
}
void turnPage(int dir) {
const uint8_t from = _page;
_page = navPage(_page, dir);
if (_page == from) return;
_quick_sel = 0; // a quick panel starts on its first row
DisplayDriver* d = _task->getDisplay();
// Pages with the header slide below it; the full-screen clock slides whole.
const bool whole = from == CLOCK || _page == CLOCK;
if (d && d->slideBegin(whole ? 0 : contentTop(*d))) {
_slide_dir = (int8_t)dir; _slide_from = from; _slide_t0 = millis();
}
}
unsigned long _advert_sent_ms = 0; // last advert sent from this page (0: none yet)
// ── Quick panels: an icon page as a few rows with margins round
// them, the last one opening the full screen. Up/Down picks a row, Enter
// acts on it. The rows are the Settings list's own (value, switch, bar).
enum QKind : uint8_t { Q_VALUE, Q_SWITCH, Q_BAR, Q_BADGE, Q_MORE };
enum QAct : uint8_t { QA_NONE, QA_MORE, QA_BRIGHTNESS, QA_GPS, QA_CHAT, QA_BT,
QA_ADVERT, QA_AUTO_ADVERT, QA_TOOL, QA_LOCK, QA_HIBERNATE };
struct QRow {
const char* label;
char value[16];
QKind kind;
QAct act;
uint8_t n; // switch on, bar level, unread count, QA_TOOL's action
bool overflow; // unread past what the badge can count
bool is_ch; // QA_CHAT: a channel (ch) or a DM / room (prefix)
uint8_t ch;
uint8_t prefix[4];
char name[24]; // QA_CHAT: the label's storage
};
static const int QUICK_MAX = 8;
// How many rows a quick panel has room for (at least the 3 a 128x64 OLED
// fits): a taller screen lists more recent chats.
int quickFit() const {
DisplayDriver* d = _task->getDisplay();
if (!d) return 3;
const int n = (d->height() - contentTop(*d) - 5) / (d->lineStep() + 1);
return n < 3 ? 3 : n > QUICK_MAX ? QUICK_MAX : n;
}
uint8_t _quick_sel = 0;
bool _quick_edit = false; // the selected row is being changed with Left/Right
bool _quick_dirty = false; // ...and has moved: saved once when it ends
static bool isQuickPage(int p) {
return p == SETTINGS || p == QUICK_MSG || p == BLUETOOTH || p == ADVERT || p == TOOLS || p == SHUTDOWN;
}
// What a tool is doing now, for its row on the Tools panel ("" when it has
// nothing running worth telling).
void toolState(ToolsScreen::Action a, char* buf, int n) {
buf[0] = 0;
switch (a) {
case ToolsScreen::ACT_TRAIL: {
TrailStore& tr = _task->trail();
if (tr.isActive()) geo::fmtDist(buf, n, tr.totalDistanceMeters() / 1000.0f, _task->useImperial(), true);
else strncpy(buf, "Off", n);
break;
}
case ToolsScreen::ACT_LIVESHARE:
strncpy(buf, _node_prefs && _node_prefs->loc_share_enabled ? "On" : "Off", n); break;
case ToolsScreen::ACT_LOCATOR:
strncpy(buf, _node_prefs && _node_prefs->locator_enabled ? "On" : "Off", n); break;
case ToolsScreen::ACT_REPEATER:
strncpy(buf, _node_prefs && _node_prefs->client_repeat ? "On" : "Off", n); break;
case ToolsScreen::ACT_AUTOADVERT:
strncpy(buf, AutoAdvertScreen::OPT_LABELS[AutoAdvertScreen::indexOf(
_node_prefs ? _node_prefs->advert_auto_interval_sec : 0)], n); break;
case ToolsScreen::ACT_CLOCK:
if (_task->isTimerRunning()) {
const uint32_t s = (_task->timerRemainingMs() + 999) / 1000;
if (s >= 3600) snprintf(buf, n, "%lu:%02lu:%02lu", (unsigned long)(s / 3600), (unsigned long)(s / 60 % 60), (unsigned long)(s % 60));
else snprintf(buf, n, "%lu:%02lu", (unsigned long)(s / 60), (unsigned long)(s % 60));
}
break;
default: break;
}
buf[n - 1] = 0;
}
// The `max` newest conversations, DM / room or channel, newest first.
int recentChats(QRow* out, int max) {
MessageHistory& h = _task->core().history;
struct Cand { bool is_ch; uint8_t ch; uint8_t prefix[4]; uint32_t ts; } c[2 * QUICK_MAX];
if (max > QUICK_MAX) max = QUICK_MAX;
int nc = 0;
for (int j = 0; j < h.dmHistCount() && nc < max; j++) {
const DmHistEntry& e = h.dmAtPos(h.dmHistPosNewest(j));
bool seen = false;
for (int k = 0; k < nc; k++) if (memcmp(c[k].prefix, e.prefix, 4) == 0) seen = true;
if (seen) continue;
c[nc].is_ch = false; c[nc].ch = 0; memcpy(c[nc].prefix, e.prefix, 4); c[nc].ts = e.timestamp; nc++;
}
const int nd = nc;
for (int j = 0; j < h.chHistCount() && nc < nd + max; j++) {
const ChHistEntry& e = h.chAtPos(h.chHistPosNewest(j));
bool seen = false;
for (int k = nd; k < nc; k++) if (c[k].ch == e.ch_idx) seen = true;
if (seen) continue;
c[nc].is_ch = true; c[nc].ch = e.ch_idx; c[nc].ts = e.timestamp; nc++;
}
for (int a = 0; a < nc; a++) // newest first
for (int b = a + 1; b < nc; b++)
if (c[b].ts > c[a].ts) { Cand t = c[a]; c[a] = c[b]; c[b] = t; }
const uint32_t now = rtc_clock.getCurrentTime();
int n = 0;
for (int k = 0; k < nc && n < max; k++) {
QRow& r = out[n];
memset(&r, 0, sizeof(r));
r.act = QA_CHAT; r.is_ch = c[k].is_ch;
if (r.is_ch) {
ChannelDetails cd;
if (!the_mesh.getChannel(c[k].ch, cd) || !cd.name[0]) continue;
r.ch = c[k].ch;
snprintf(r.name, sizeof(r.name), "#%s", chanctl::bareName(cd.name));
r.n = _task->getChannelUnread(r.ch);
r.overflow = r.n > 0 && _task->getChannelUnreadOverflow(r.ch);
} else {
ContactInfo ci;
if (!MessageHistory::contactByPrefix(c[k].prefix, ci)) continue;
memcpy(r.prefix, c[k].prefix, 4);
snprintf(r.name, sizeof(r.name), "%s", ci.name);
r.n = _task->getDMUnread(ci.id.pub_key);
r.overflow = r.n > 0 && _task->getDMUnreadOverflow(ci.id.pub_key);
}
r.label = r.name;
if (r.n > 0) r.kind = Q_BADGE;
else { r.kind = Q_VALUE; geo::fmtAgeShort(r.value, sizeof(r.value), now, c[k].ts); }
n++;
}
return n;
}
int buildQuick(int page, QRow* rows) {
int n = 0;
auto add = [&](const char* label, QKind k, QAct a, uint8_t v) {
QRow& r = rows[n++];
memset(&r, 0, sizeof(r));
r.label = label; r.kind = k; r.act = a; r.n = v;
};
// A panel with a whole screen behind it lists the way in first, its
// shortcuts after.
if (page == SETTINGS) {
add("All settings", Q_MORE, QA_MORE, 0);
#if FEAT_BRIGHTNESS_SETTING
add("Brightness", Q_BAR, QA_BRIGHTNESS, _node_prefs ? _node_prefs->display_brightness + 1 : 0);
#endif
#if ENV_INCLUDE_GPS == 1
if (sensors.getLocationProvider()) add("GPS", Q_SWITCH, QA_GPS, _task->getGPSState() ? 1 : 0);
#endif
} else if (page == QUICK_MSG) {
add("All messages", Q_MORE, QA_MORE, 0);
n += recentChats(rows + n, quickFit() - 1);
} else if (page == BLUETOOTH) {
const bool on = _task->isSerialEnabled();
add("Bluetooth", Q_SWITCH, QA_BT, on ? 1 : 0);
add("Phone", Q_VALUE, QA_NONE, 0);
char* v = rows[n - 1].value;
if (!on) strcpy(v, "-");
else if (_task->isBLEConnected()) strcpy(v, "Connected");
// The pairing PIN is BLE-specific: show it while BLE is on but not yet
// bonded. (Gating on a plain isConnected() broke this on dual builds,
// where it's hardcoded true.)
else if (the_mesh.getBLEPin() != 0) snprintf(v, sizeof(rows[0].value), "PIN %d", (int)the_mesh.getBLEPin());
else strcpy(v, "Waiting");
} else if (page == ADVERT) {
add("Send now", Q_VALUE, QA_ADVERT, 0);
if (_advert_sent_ms) { // when this page last sent one
const unsigned long ago = (millis() - _advert_sent_ms) / 60000UL;
char* v = rows[n - 1].value;
if (ago == 0) strcpy(v, "sent");
else if (ago < 60) snprintf(v, sizeof(rows[0].value), "%lum ago", ago);
else snprintf(v, sizeof(rows[0].value), "%luh ago", ago / 60);
}
add("Auto", Q_VALUE, QA_AUTO_ADVERT, 0);
strcpy(rows[n - 1].value, AutoAdvertScreen::OPT_LABELS[AutoAdvertScreen::indexOf(
_node_prefs ? _node_prefs->advert_auto_interval_sec : 0)]);
} else if (page == TOOLS) {
add("All tools", Q_MORE, QA_MORE, 0);
for (int i = 0; i < ToolsScreen::RECENT_MAX; i++) {
const ToolsScreen::Action a = ToolsScreen::s_recent[i];
add(ToolsScreen::labelOf(a), Q_VALUE, QA_TOOL, (uint8_t)a);
toolState(a, rows[n - 1].value, sizeof(rows[0].value));
}
} else if (page == SHUTDOWN) {
add("Lock screen", Q_VALUE, QA_LOCK, 0);
add("Hibernate", Q_VALUE, QA_HIBERNATE, 0);
// The charge left, which is what you weigh before switching off.
const int mv = _task->getBattMilliVolts();
snprintf(rows[n - 1].value, sizeof(rows[0].value), "%d%%%s",
battery::percent(mv, _node_prefs ? (int)_node_prefs->low_batt_mv : 0),
board.isExternalPowered() ? " USB" : "");
}
return n;
}
// Small arrows either side of x0..x0+w while Left/Right change a row.
static void drawEditArrows(DisplayDriver& d, int x0, int w, int cy, int s) {
for (int i = 0; i < 3; i++) {
d.fillRect(x0 - 6 * s + i * s, cy - i * s, s, (2 * i + 1) * s); // ◂
d.fillRect(x0 + w + 5 * s - i * s, cy - i * s, s, (2 * i + 1) * s); // ▸
}
}
// The rows, from a gap below the page icons and inset from both sides.
int drawQuickPanel(DisplayDriver& d, int top, QRow* rows, int n) {
const int W = d.width(), m = W / 16, lh = d.getLineHeight(), step = d.lineStep() + 1;
const int reserve = m - 2; // so a row's right edge is W - m
if (_quick_sel >= n) _quick_sel = n - 1;
int y = top + 5, mq = 0;
for (int i = 0; i < n; i++, y += step) {
const QRow& r = rows[i];
const bool sel = i == _quick_sel;
d.setColor(DisplayDriver::LIGHT);
if (sel) { d.fillSoftRect(m - 3, y - 1, W - 2 * m + 6, lh + 1); d.setColor(DisplayDriver::DARK); }
switch (r.kind) {
case Q_SWITCH: info::switchRow(d, y, r.label, r.n != 0, sel, reserve, m); break;
case Q_BAR: {
d.setCursor(m, y); d.print(r.label);
const int box = lh - 3, gap = 2, s = miniIconScale(d), bars = 5 * box + 4 * gap;
const bool editing = sel && _quick_edit;
const int x0 = W - m - bars - (editing ? 6 * s : 0);
if (editing) drawEditArrows(d, x0, bars, y + 1 + box / 2, s);
for (int k = 0; k < 5; k++) {
const int bx = x0 + k * (box + gap);
if (k < r.n) d.fillRect(bx, y + 1, box, box); else d.drawRect(bx, y + 1, box, box);
}
break;
}
case Q_BADGE: {
const int bw = d.unreadBadgeWidth(r.n, r.overflow);
d.drawTextEllipsized(m, y, W - 2 * m - bw - 4, r.label);
d.drawUnreadBadge(W - m, y, r.n, sel, r.overflow);
break;
}
case Q_MORE:
d.setCursor(m, y); d.print(r.label);
d.drawTextRightAlign(W - m, y, ">");
break;
default:
if (sel && _quick_edit) { // a value being changed: arrows round it
const int s = miniIconScale(d), vw = d.getTextWidth(r.value), x0 = W - m - vw - 6 * s;
d.setCursor(m, y); d.print(r.label);
d.setCursor(x0, y); d.print(r.value);
drawEditArrows(d, x0, vw, y + 1 + (lh - 3) / 2, s);
} else {
int q = info::valueRow(d, y, r.label, r.value, sel, reserve, m);
if (q > 0) mq = q;
}
}
d.setColor(DisplayDriver::LIGHT);
}
return mq;
}
void quickAct(int page, const QRow& r) {
switch (r.act) {
case QA_NONE: break;
case QA_MORE:
if (page == SETTINGS) _task->gotoSettingsScreen();
else if (page == TOOLS) _task->gotoToolsScreen();
else _task->gotoMessagesScreen();
break;
case QA_BRIGHTNESS:
case QA_AUTO_ADVERT: _quick_edit = true; break; // Left/Right change it, Enter / Back keep it
case QA_BT:
if (_task->isSerialEnabled()) _task->disableSerial(); else _task->enableSerial();
break;
case QA_ADVERT:
_task->notify(UIEventType::ack);
if (the_mesh.advert()) _advert_sent_ms = millis(); // the row says so
else _task->showAlert("Advert failed", 1000);
break;
case QA_TOOL: ToolsScreen::run(_task, (ToolsScreen::Action)r.n, true); break;
case QA_LOCK: _task->lockScreen(); break;
case QA_HIBERNATE: _shutdown_init = true; break; // waits for the button to be released
case QA_GPS: _task->toggleGPS(false); break;
case QA_CHAT:
if (r.is_ch) { _task->openChannelHistory(r.ch); break; }
{
ContactInfo ci;
if (!MessageHistory::contactByPrefix(r.prefix, ci)) break;
if (ci.type == ADV_TYPE_ROOM) _task->openRoomServer(ci); else _task->openContactDM(ci);
}
break;
}
}
int pageBit(int page) const {
if (page == CLOCK) return NodePrefs::HPB_CLOCK;
if (page == FAVOURITES) return NodePrefs::HPB_FAVOURITES;
if (page == STATUS) return NodePrefs::HPB_RADIO; // the old Radio slot
if (page == BLUETOOTH) return NodePrefs::HPB_BLUETOOTH;
if (page == ADVERT) return NodePrefs::HPB_ADVERT;
if (page == TOOLS) return NodePrefs::HPB_TOOLS;
if (page == SHUTDOWN) return NodePrefs::HPB_SHUTDOWN;
if (page == MAP) return NodePrefs::HPB_MAP;
return -1; // SETTINGS, QUICK_MSG always visible (no mask bit)
}
// Maps page_order bit-index back to the HomePage enum value for this build.
// Returns -1 if the page is not compiled in.
int bitToPage(int bit) const {
switch (bit) {
case NodePrefs::HPB_CLOCK: return CLOCK;
case NodePrefs::HPB_FAVOURITES: return FAVOURITES;
case NodePrefs::HPB_RADIO: return STATUS;
case NodePrefs::HPB_BLUETOOTH: return BLUETOOTH;
case NodePrefs::HPB_ADVERT: return ADVERT;
case NodePrefs::HPB_TOOLS: return TOOLS;
case NodePrefs::HPB_SHUTDOWN: return SHUTDOWN;
case NodePrefs::HPB_SETTINGS: return SETTINGS;
case NodePrefs::HPB_QUICK_MSG: return QUICK_MSG;
case NodePrefs::HPB_MAP: return MAP;
default: return -1;
}
}
bool isPageVisible(int page) const {
int bit = pageBit(page);
if (bit < 0) return true;
uint16_t mask = (_node_prefs && _node_prefs->home_pages_mask) ? _node_prefs->home_pages_mask : NodePrefs::HP_ALL;
// Status stands in for the Radio, GPS and Sensors pages: shown if any was.
if (page == STATUS) return (mask & (NodePrefs::HP_RADIO | NodePrefs::HP_GPS | NodePrefs::HP_SENSORS)) != 0;
return (mask >> bit) & 1;
}
// Build ordered list of all visible pages, respecting page_order when set.
// Returns count; out[] receives HomePage enum values.
int buildVisibleOrder(int* out) const {
int n = 0;
bool custom = _node_prefs && _node_prefs->page_order_set == NodePrefs::PAGE_ORDER_MAGIC;
if (custom) {
for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) {
uint8_t v = _node_prefs->page_order[i];
if (v < 1 || v > NodePrefs::HPB_COUNT) break;
int pg = bitToPage(v - 1);
if (pg >= 0 && pg < (int)Count && isPageVisible(pg)) out[n++] = pg;
}
// Append any visible page missing from page_order (handles corrupted/migrated prefs)
for (int pg = 0; pg < (int)Count; pg++) {
if (!isPageVisible(pg)) continue;
bool found = false;
for (int i = 0; i < n; i++) if (out[i] == pg) { found = true; break; }
if (!found) out[n++] = pg;
}
} else {
for (int pg = 0; pg < (int)Count; pg++)
if (isPageVisible(pg)) out[n++] = pg;
}
return n;
}
int navPage(int from, int dir) const {
int order[(int)Count]; int n = buildVisibleOrder(order);
if (n == 0) return from;
int cur = 0;
for (int i = 0; i < n; i++) if (order[i] == from) { cur = i; break; }
return order[((cur + dir) % n + n) % n];
}
// reserve_left: how much width from x=0 must stay clear of status icons --
// the time / node name on the pages (name_min below); the lock page passes
// 0, its clock sits under the bar. -1 = use the normal name reserve.
int renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts, int reserve_left = -1) {
int low_mv = _node_prefs ? (int)_node_prefs->low_batt_mv : 0;
int pct = battery::percent((int)batteryMilliVolts, low_mv);
uint8_t mode = battery::mode(_node_prefs ? _node_prefs->batt_display_mode : 0);
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
const int lh = display.getLineHeight();
const int cw = display.getCharWidth();
const int sis = statusIconScale(display); // STATUS_ICON_SCALE, or the font's
const int bar_h = statusBarH(display);
const int ind = STATUS_ICONS_SCALED ? 7 * sis + 2 : cw + 2; // indicator slot width
const int ind_h = (bar_h > lh) ? bar_h - 2 : (display.isSingleFont() ? lh - 2 : lh);
const int text_y = (bar_h > lh) ? (bar_h - lh) / 2 : 0; // % / V text centred in a tall bar
const int ind_gap = display.pixelScale() > 1 ? 3 : 1; // gap between indicator boxes
int battLeftX;
if (mode == battery::PERCENT) {
char buf[6];
snprintf(buf, sizeof(buf),"%d%%", pct);
battLeftX = display.width() - display.getTextWidth(buf) - 1;
display.setCursor(battLeftX, text_y);
display.print(buf);
} else if (mode == battery::VOLTAGE) {
char buf[8];
snprintf(buf, sizeof(buf),"%u.%02uV", batteryMilliVolts / 1000, (batteryMilliVolts % 1000) / 10);
battLeftX = display.width() - display.getTextWidth(buf) - 1;
display.setCursor(battLeftX, text_y);
display.print(buf);
} else { // icon — scales with lh, same box height as the status icons beside it (ind_h)
const int iconH = ind_h;
const int iconW = (bar_h > lh) ? ind_h * 2 : lh * 2;
const int bm = display.pixelScale() > 1 ? 3 : 2; // inner margin: 3px on a doubled panel, else 2px
battLeftX = display.width() - iconW - 3;
// The outline with its corner pixels left out, softly rounded.
display.fillRect(battLeftX + 1, 0, iconW - 2, 1);
display.fillRect(battLeftX + 1, iconH - 1, iconW - 2, 1);
display.fillRect(battLeftX, 1, 1, iconH - 2);
display.fillRect(battLeftX + iconW - 1, 1, 1, iconH - 2);
// Nub height/2, vertically centred by remaining-space/2 rather than a flat
// iconH/4 margin — the flat form only centres when iconH is a multiple of
// 4 (true for the old built-in font's lh=8, false for misc-fixed's 7/9),
// so it visibly drifted off-centre once the box height changed.
const int nub_h = iconH / 2;
display.fillRect(battLeftX + iconW, (iconH - nub_h) / 2, 2, nub_h);
int fillW = (pct * (iconW - 2 * bm)) / 100;
display.fillRect(battLeftX + bm, bm, fillW, iconH - 2 * bm);
}
// Secondary status icons, laid out right→left in PRIORITY order so a crowded
// bar sheds its least-important cues instead of crushing the time. Once an
// icon won't fit above the reserved area, every lower-priority icon after
// it is dropped too (the list is ordered high→low).
//
// Priority: power > BT > GPS fix > alarm > mute > auto-advert > trail > live-share >
// repeater. Battery (drawn above) is always rightmost. The background modes
// (advert / trail / live-share / repeater) stay outside any BT gate — they
// keep running with Bluetooth off, so their cue must not vanish with it.
LocationProvider* loc = _sensors ? _sensors->getLocationProvider() : nullptr;
bool gps_on = loc && _node_prefs && _node_prefs->gps_enabled;
bool mute_on = false;
#ifdef PIN_BUZZER
mute_on = _task->isBuzzerQuiet();
#endif
// Plain glyphs, no blinking: each one shows a state by its shape.
// Bluetooth only once a phone is connected (it's on nearly always; the
// Bluetooth page tells the rest), GPS as a broken reticle until a fix.
const bool gps_fix = gps_on && loc->isValid();
struct Sicon { bool active; const MiniIcon* icon; };
const Sicon icons[] = {
{ board.isExternalPowered(), &ICON_CHARGE }, // USB power: next to the battery
{ _task->isSerialEnabled() && _task->isBLEConnected(), &ICON_BLUETOOTH },
{ gps_on, gps_fix ? &ICON_GPS : &ICON_GPS_SEARCH },
{ _node_prefs && _node_prefs->alarm_on, &ICON_ALARM },
{ mute_on, &ICON_MUTE },
{ _node_prefs && _node_prefs->advert_auto_interval_sec > 0, &ICON_ADVERT },
{ _task->trail().isActive(), &ICON_TRAIL },
{ _node_prefs && _node_prefs->loc_share_enabled, &ICON_MAP_CONTACT },
{ _node_prefs && _node_prefs->client_repeat, &ICON_REPEATER },
};
int x = battLeftX;
const int name_min = (reserve_left >= 0) ? reserve_left : display.getCharWidth() * 5;
// Settings > Message alert Compact: the envelope and its count, first by the
// battery; its place is kept through the blink so nothing beside it jumps.
if (const int n = _task->msgBadgeCount()) {
const int ix = x - mailCountWidth(display, n) - ind_gap - 2 * miniIconScale(display); // the icons' boxes pad theirs
if (ix >= name_min) {
if (_task->msgBadgeLit()) drawMailCount(display, ix, 0, ind_h, n);
_task->msgBadgeInBar();
x = ix;
}
}
for (const Sicon& s : icons) {
if (!s.active) continue;
int ix = x - ind - ind_gap;
if (ix < name_min) break; // out of room — drop this + all lower priority
if (STATUS_ICONS_SCALED) drawSlotIconScaled(display, ix, ind, ind_h, *s.icon, sis);
else drawSlotIcon(display, ix, ind, ind_h, *s.icon);
x = ix;
}
return x;
}
CayenneLPP sensors_lpp;
int sensors_nb = 0;
unsigned long next_sensors_refresh = 0; // 0: read on the next call
void refresh_sensors() {
if (!next_sensors_refresh || (int32_t)(millis() - next_sensors_refresh) >= 0) {
sensors_lpp.reset();
sensors_nb = 0;
sensors_lpp.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f);
{ CardKBBusLock bus_lock; sensors.querySensors(0xFF, sensors_lpp); }
LPPReader reader (sensors_lpp.getBuffer(), sensors_lpp.getSize());
uint8_t channel, type;
while(reader.readHeader(channel, type)) {
reader.skipData(type);
sensors_nb ++;
}
#if AUTO_OFF_MILLIS > 0
next_sensors_refresh = (millis() + 5000) | 1; // refresh sensor values every 5 sec
#else
next_sensors_refresh = (millis() + 60000) | 1; // refresh sensor values every 1 min
#endif
}
}
public:
HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, NodePrefs* node_prefs)
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0),
_prev_page(0), _shutdown_init(false), sensors_lpp(200) { }
// Flips home screen between LOCK page and the page that was showing before locking
void setLocked(bool locked) {
if (locked) {
if (_page != LOCK) _prev_page = _page;
_page = LOCK;
} else {
_page = _prev_page;
}
}
void poll() override {
if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released
// _shutdown_init is never cleared elsewhere -- on real hardware
// that's harmless because _board->powerOff() halts the MCU, so
// there is no next poll() tick to matter. In the sim, powerOff()
// is a deliberate no-op (no real hardware to power off, see
// SimMainBoard.h), so without this the instance keeps running and
// this branch re-fires shutdown() -> _display->turnOff() on every
// single tick forever, repeatedly blacking out its canvas -- which
// fights with a freshly reset instance's own boot splash trying to
// render onto that same (simInstanceTag-keyed) canvas element.
_shutdown_init = false;
_task->shutdown();
}
}
// Compact map preview for the Home "Map" page: own position, the GPS trail,
// and live-tracked contacts (◆) folded into one auto-scaled box. A simplified
// cousin of TrailScreen's map (no grid/labels, no break markers) so the home
// carousel stays light. Returns false (and draws nothing) when there's
// nothing to show.
bool drawMapPreview(DisplayDriver& display, int ax, int ay, int aw, int ah) {
if (aw < 8 || ah < 8) return false;
bool init = false;
int32_t mnla = 0, mxla = 0, mnlo = 0, mxlo = 0;
auto fold = [&](int32_t la, int32_t lo) {
if (!init) { mnla = mxla = la; mnlo = mxlo = lo; init = true; }
else { if (la < mnla) mnla = la; if (la > mxla) mxla = la;
if (lo < mnlo) mnlo = lo; if (lo > mxlo) mxlo = lo; }
};
TrailStore& tr = _task->trail();
if (!tr.empty()) { int32_t a, b, c, d; tr.boundingBox(a, b, c, d); fold(a, b); fold(c, d); }
LiveTrackStore& lt = _task->liveTrack();
uint32_t now = rtc_clock.getCurrentTime();
for (int i = 0; i < LiveTrackStore::CAPACITY; i++)
if (lt.isActive(i, now)) fold(lt.slotAt(i).lat_1e6, lt.slotAt(i).lon_1e6);
int32_t mla, mlo;
bool have_gps = _task->currentLocation(mla, mlo);
if (have_gps) fold(mla, mlo);
int32_t tla, tlo;
bool have_tgt = _task->activeTargetPos(tla, tlo); // active Locator/Nav target
if (have_tgt) fold(tla, tlo);
if (!init) return false;
// North marker — top-right, the same mini-icon as the full Trail map.
display.setColor(DisplayDriver::LIGHT);
{
const int ns = miniIconScale(display);
miniIconDrawTop(display, ax + aw - ICON_MAP_NORTH.w * ns - 1, ay + 1, ICON_MAP_NORTH);
}
int cx = ax + aw / 2, cy = ay + ah / 2;
// Degenerate: one coincident point — just centre the markers.
if (mnla == mxla && mnlo == mxlo) {
for (int i = 0; i < LiveTrackStore::CAPACITY; i++)
if (lt.isActive(i, now)) { miniIconDrawCentered(display, cx, cy, ICON_MAP_CONTACT); break; }
if (have_gps || !tr.empty()) miniIconDrawCentered(display, cx, cy, ICON_MAP_CURRENT);
if (have_tgt) miniIconDrawCentered(display, cx, cy, ICON_MAP_TARGET); // highlight on top
return true;
}
float avg_lat_rad = ((mnla + mxla) / 2.0e6f) * (float)M_PI / 180.0f;
float lon_scale = cosf(avg_lat_rad); if (lon_scale < 0.05f) lon_scale = 0.05f;
float lat_span = (float)(mxla - mnla);
float lon_span = (float)(mxlo - mnlo) * lon_scale;
float slat = (float)ah / (lat_span > 0 ? lat_span : 1.0f);
float slon = (float)aw / (lon_span > 0 ? lon_span : 1.0f);
float scale = (slat < slon) ? slat : slon;
int off_x = ax + (aw - (int)(lon_span * scale)) / 2;
int off_y = ay + (ah - (int)(lat_span * scale)) / 2;
auto project = [&](int32_t la, int32_t lo, int& px, int& py) {
px = off_x + (int)((float)(lo - mnlo) * lon_scale * scale);
py = off_y + (int)((float)(mxla - la) * scale);
};
// Trail as a connected line, matching the full Trail map (shared helper —
// see gfx::drawTrail); no break marker here, just a silent gap.
gfx::drawTrail(display, tr, project, [](int, int, int, int) {});
for (int i = 0; i < LiveTrackStore::CAPACITY; i++) {
if (!lt.isActive(i, now)) continue;
int px, py; project(lt.slotAt(i).lat_1e6, lt.slotAt(i).lon_1e6, px, py);
miniIconDrawCentered(display, px, py, ICON_MAP_CONTACT);
}
if (have_gps) { int px, py; project(mla, mlo, px, py); miniIconDrawCentered(display, px, py, ICON_MAP_CURRENT); }
// Active target flag drawn last so it stays legible even atop a contact/own dot.
if (have_tgt) { int px, py; project(tla, tlo, px, py); miniIconDrawCentered(display, px, py, ICON_MAP_TARGET); }
// Bottom-left scale reference, always shown — distance to the active
// target (or else the nearest live-tracked contact) now lives on the
// status line below instead (see statusDistanceKm() / render()), so this
// corner is free for it.
{
display.setColor(DisplayDriver::LIGHT);
int ty = ay + ah - display.getLineHeight();
static const float M_PER_1E6 = 0.11132f; // metres per 1e-6° lat
float ppm = scale / M_PER_1E6; // pixels per metre
if (ppm > 0.0f) {
bool imp = _task->useImperial();
static const float MET_M[] = { 5,10,25,50,100,250,500,1000,2000,5000,10000,25000,50000 };
static const char* MET_L[] = { "5m","10m","25m","50m","100m","250m","500m","1km","2km","5km","10km","25km","50km" };
static const float IMP_M[] = { 4.572f,15.24f,30.48f,76.2f,152.4f,402.34f,804.67f,1609.34f,4828.0f,16093.4f,80467.2f };
static const char* IMP_L[] = { "15ft","50ft","100ft","250ft","500ft","1/4mi","1/2mi","1mi","3mi","10mi","50mi" };
const float* M = imp ? IMP_M : MET_M;
const char* const* L = imp ? IMP_L : MET_L;
int N = imp ? (int)(sizeof(IMP_M) / sizeof(IMP_M[0])) : (int)(sizeof(MET_M) / sizeof(MET_M[0]));
float target = 8.0f / ppm; // short reference tick, not 1/3 of the width
int sel = 0;
for (int i = N - 1; i >= 0; i--) if (M[i] <= target) { sel = i; break; }
int barpx = (int)(M[sel] * ppm + 0.5f);
if (barpx < 5) barpx = 5;
if (barpx > aw / 4) barpx = aw / 4;
int bx = ax + 1, mid = ty + display.getLineHeight() / 2;
display.fillRect(bx, mid, barpx, 1); // single tick, on the text baseline
display.setCursor(bx + barpx + 2, ty);
display.print(L[sel]);
}
}
return true;
}
// Distance shown on the MAP status line. The active Locator/Nav target
// takes priority — that's what the flag on the mini-map is pointing at,
// and it's the only way a waypoint target ever gets a distance readout
// here (a waypoint isn't a live-tracked contact). Falls back to the
// nearest live-tracked ([LOC]-sharing) contact when no target is set.
// -1 when we don't have a fix or nothing to measure against.
float statusDistanceKm() {
int32_t mla, mlo;
if (!_task->currentLocation(mla, mlo)) return -1.0f;
int32_t tla, tlo;
if (_task->activeTargetPos(tla, tlo)) return geo::haversineKm(mla, mlo, tla, tlo);
LiveTrackStore& lt = _task->liveTrack();
uint32_t now = rtc_clock.getCurrentTime();
float nearest_km = -1.0f;
for (int i = 0; i < LiveTrackStore::CAPACITY; i++) {
if (!lt.isActive(i, now)) continue;
float d = geo::haversineKm(mla, mlo, lt.slotAt(i).lat_1e6, lt.slotAt(i).lon_1e6);
if (nearest_km < 0.0f || d < nearest_km) nearest_km = d;
}
return nearest_km;
}
// The Map page: the mini-map in a soft frame on the left, what it shows
// spelled out in a column on the right (GPS, the trail, live contacts or
// the target). Without a position the column still says what's missing.
// A tall screen (portrait e-ink) stacks them: the map, the four rows under it.
void drawMapPage(DisplayDriver& d, int top) {
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 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)) {
// An empty sheet: a sparse dot grid with the reason over it.
for (int gy = top + 4; gy < top + ph - 2; gy += 6 * s)
for (int gx = 4; gx < pw - 2; gx += 6 * s) d.fillRect(gx, gy, s, s);
const char* why = "No fix";
const int tw = d.getTextWidth(why), tx = (pw - tw) / 2, ty = top + (ph - lh) / 2;
d.setColor(DisplayDriver::DARK);
d.fillRect(tx - 2, ty - 1, tw + 4, lh + 2);
d.setColor(DisplayDriver::LIGHT);
d.setCursor(tx, ty);
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;
int y = (tall ? top + ph : top) + (row_h - lh) / 2 + 1;
auto line = [&](const MiniIcon& ic, const char* text) {
miniIconDraw(d, cx, y, ic);
const int tx = cx + ic.w * s + 2 * s;
d.drawTextEllipsized(tx, y, cx + cw - tx, text);
y += row_h;
};
char buf[20];
#if ENV_INCLUDE_GPS == 1
LocationProvider* loc = sensors.getLocationProvider();
const bool on = _task->getGPSState();
if (!loc) strcpy(buf, "No GPS");
else if (!on) strcpy(buf, "Off");
else if (!loc->isValid()) strcpy(buf, "No fix");
else snprintf(buf, sizeof(buf), "%ld sats", loc->satellitesCount());
#else
strcpy(buf, "No GPS");
#endif
line(ICON_GPS, buf);
TrailStore& tr = _task->trail();
if (tr.empty() && !tr.isActive()) strcpy(buf, "No trail");
else geo::fmtDist(buf, sizeof(buf), tr.totalDistanceMeters() / 1000.0f, _task->useImperial(), true);
line(ICON_TRAIL, buf);
const float km = statusDistanceKm();
int32_t tla, tlo;
const bool target = _task->activeTargetPos(tla, tlo);
const int live = _task->liveTrack().active(rtc_clock.getCurrentTime());
if (live > 0 && km >= 0 && !target) geo::fmtDist(buf, sizeof(buf), km, _task->useImperial(), true);
else snprintf(buf, sizeof(buf), "%d live", live);
line(ICON_MAP_CONTACT, buf);
if (target) {
if (km >= 0) geo::fmtDist(buf, sizeof(buf), km, _task->useImperial(), true);
else strcpy(buf, "-");
line(ICON_MAP_TARGET, buf);
} else {
snprintf(buf, sizeof(buf), "%d wpts", _task->waypoints().count());
line(ICON_MAP_WAYPOINT, buf);
}
}
// 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;
if (_node_prefs)
for (int k = 0; k < 3; k++)
if (_node_prefs->dashboard_fields[k] != telemetry::NONE && _node_prefs->dashboard_fields[k] < telemetry::COUNT)
out[n++] = _node_prefs->dashboard_fields[k];
return n;
}
// One field's value without its noun (the label beside it names it); "-" when there is none.
void clockFieldValue(uint8_t f, char* val, int n) {
formatDashVal(f, val, n, _task->getBattMilliVolts(), _node_prefs->low_batt_mv,
_task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount(),
_task->getAnyUnreadOverflow(), _node_prefs->units_imperial, &sensors_lpp, false);
if (!val[0]) snprintf(val, n, "-");
}
// ── Clock page: the big clock, a seconds bar under it, the date, and the
// clock fields (Settings) as columns along the bottom, name over value.
// Without fields the clock and date sit in the middle.
// The days left this month the alarm goes off on, as calendar::draw()'s
// marks: the repeat days, or a one-shot's next (today or tomorrow).
uint32_t alarmDays(const struct tm& t) const {
if (!_node_prefs) return 0;
return calmath::alarmDays(t, _node_prefs->alarm_on, _node_prefs->alarm_repeat_mask,
_node_prefs->alarm_hour, _node_prefs->alarm_min);
}
void drawClockPage(DisplayDriver& d, uint32_t unix_ts) {
struct tm ti;
localTm(unix_ts, _node_prefs ? _node_prefs->tz_offset_hours : 0, ti);
const bool h12 = _node_prefs && _node_prefs->clock_12h;
const bool show_sec = !Features::IS_EINK && (!_node_prefs || !_node_prefs->clock_hide_seconds);
const int W = d.width(), H = d.height();
d.setColor(DisplayDriver::LIGHT);
d.setTextSize(1);
const int lh = d.getLineHeight();
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;
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
const int x0 = W / 8, bw = W - 2 * x0, fill = bw * ti.tm_sec / 60;
d.fillRect(x0, bar_y, fill, 1);
for (int x = x0 + fill + (fill & 1); x < x0 + bw; x += 2) d.fillRect(x, bar_y, 1, 1);
}
// Alarm armed: the bell at the bar's left end (the status bar, which
// shows it everywhere else, is hidden on this page).
if (_node_prefs && _node_prefs->alarm_on) miniIconDrawTop(d, W / 32, bar_y - 2, ICON_ALARM);
date_y = y + bc.h + bar + 1;
}
char buf[24];
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);
const int cal_top = date_y + lh + lh / 2 + 2;
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();
const int val_y = H - lh, lab_y = val_y - lh - 1, cw = W / nf;
for (int k = 0; k < nf; k++) {
char val[20];
clockFieldValue(fields[k], val, sizeof(val));
const int x0 = k * cw;
auto cell = [&](int yy, const char* t) {
if ((int)d.getTextWidth(t) <= cw - 2) d.drawTextCentered(x0 + cw / 2, yy, t);
else d.drawTextEllipsized(x0 + 1, yy, cw - 2, t);
};
cell(lab_y, telemetry::LABEL[fields[k]]);
cell(val_y, val);
}
}
// ── Lock screen: the status bar (drawn by render()), then one of two
// looks (Settings > Display > Lock clock):
// big -- the big clock, the date, unread messages in a pill;
// compact -- a small clock with the date beside it, the clock fields as
// rows below (plus unread messages when no field shows them).
// A key press on the lit screen (not the one that wakes it, so a glance
// shows the data) puts the unlock hint in a pill at the bottom for a few
// seconds (UITask::lockHintShown); e-ink keeps it up, it has no glance.
void drawLockPage(DisplayDriver& d) {
const int W = d.width(), H = d.height();
d.setColor(DisplayDriver::LIGHT);
d.setTextSize(1);
const int lh = d.getLineHeight(), top = statusBarH(d) + 3;
const bool hint = Features::IS_EINK || _task->lockHintShown();
const int pill_y = H - lh - 3; // the bottom pill, clear of what's above
const int unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount();
const uint32_t unix_ts = _rtc->getCurrentTime();
const bool synced = unix_ts >= 1000000000UL;
struct tm ti;
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) {
const int m = W / 16, x0 = W / 32;
int sc = bigClockSize(d).sc / 2;
if (sc < 1) sc = 1;
const int ch = lettering::LOGO_H * sc;
if (!synced) {
d.setCursor(x0, top + (ch - lh + 1) / 2);
d.print("No time sync");
} else { // the date beside the clock, without the weekday if it won't fit
BigClockText bt(d, &ti, h12, sc);
bt.draw(d, x0, top);
char date[16];
snprintf(date, sizeof(date), "%s %d %s", WDAY[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon]);
if (x0 + bt.w + lh / 2 + (int)d.getTextWidth(date) > W - x0)
snprintf(date, sizeof(date), "%d %s", ti.tm_mday, MON[ti.tm_mon]);
d.drawTextRightAlign(W - x0, top + (ch - lh + 1) / 2, date);
}
uint8_t rows[4];
int nr = clockFields(rows);
bool has_msgs = false;
for (int k = 0; k < nr; k++) if (rows[k] == telemetry::MSGS) has_msgs = true;
if (!has_msgs && unread > 0) rows[nr++] = telemetry::MSGS;
if (nr) refresh_sensors();
const int step = d.lineStep();
for (int k = 0, y = top + ch + 6; k < nr && y + lh <= H; k++, y += step) {
if (hint && y + lh >= pill_y) break; // the hint pill takes the bottom
char val[20];
clockFieldValue(rows[k], val, sizeof(val));
info::valueRow(d, y, telemetry::LABEL[rows[k]], val, false, m - 2, m);
}
} else {
if (!synced) {
d.drawTextCentered(W / 2, H / 2 - lh, "No time sync");
} else {
int date_y;
if (H > W) {
date_y = drawClockTall(d, top, &ti, h12);
} else {
const BigClock bc = bigClockSize(d);
drawBigClock(d, W / 2, top, &ti, h12, bc.sc);
date_y = top + bc.h + 3;
}
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 (extras) drawLockTallExtras(d, date_y + lh + lh / 2 + 2, hint ? pill_y - 3 : H, ti, unread);
}
if (!hint && unread > 0 && !(synced && extras)) snprintf(pill, sizeof(pill), "%d%s new", unread, _task->getAnyUnreadOverflow() ? "+" : "");
}
if (hint) {
const int n = _task->lockSeqCount();
#if defined(CARDKB_I2C)
const char* first = _task->hasCardKB() ? "Back+3xEnter/Fn+Esc" : "Hold Back + 3xEnter";
#else
const char* first = "Hold Back + 3xEnter";
#endif
snprintf(pill, sizeof(pill), "%s", n == 0 ? first : n == 1 ? "Enter x2 more..." : "Enter x1 more...");
}
if (!pill[0]) return;
const int pw = d.getTextWidth(pill) + 6;
d.fillSoftRect((W - pw) / 2, pill_y, pw, lh + 3);
d.setColor(DisplayDriver::DARK);
d.drawTextCentered(W / 2, pill_y + 2, pill);
d.setColor(DisplayDriver::LIGHT);
}
// 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) {
const int W = d.width(), m = W / 16, step = d.lineStep(), s = miniIconScale(d);
struct Row { const MiniIcon* ic; const char* label; char value[20]; } rows[6];
int n = 0;
auto add = [&](const MiniIcon* ic, const char* label) -> char* {
rows[n].ic = ic; rows[n].label = label; rows[n].value[0] = 0;
return rows[n++].value;
};
if (unread > 0)
snprintf(add(&ICON_PG_MSG, "Messages"), sizeof(rows[0].value), "%d%s new", unread, _task->getAnyUnreadOverflow() ? "+" : "");
if (_node_prefs && _node_prefs->alarm_on)
snprintf(add(&ICON_ALARM, "Alarm"), sizeof(rows[0].value), "%02d:%02d", _node_prefs->alarm_hour, _node_prefs->alarm_min);
uint8_t fields[3];
const int nf = clockFields(fields);
if (nf) refresh_sensors();
for (int k = 0; k < nf && n < 6; k++) {
if (fields[k] == telemetry::MSGS && unread > 0) continue; // already a row
clockFieldValue(fields[k], add(nullptr, telemetry::LABEL[fields[k]]), sizeof(rows[0].value));
}
const int rows_h = n * step;
if (calendar::height(d, ti) + d.getLineHeight() / 2 + rows_h <= bottom - y) {
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) {
int x = m;
if (rows[k].ic) { miniIconDraw(d, x, y, *rows[k].ic); x += rows[k].ic->w * s + 2 * s; }
info::valueRow(d, y, rows[k].label, rows[k].value, false, m - 2, x);
}
}
// The Status page: four tiles split by dotted rules, each one subject's
// 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);
// 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);
};
// 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);
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
{
LocationProvider* loc = sensors.getLocationProvider();
const bool on = _task->getGPSState();
t[0] = 0;
if (!loc) strcpy(h, "No GPS");
else if (!on) strcpy(h, "GPS off");
else if (!loc->isValid()) strcpy(h, "No fix");
else {
#ifdef STATUS_HAS_SKY
GpsSky* g = skyview::sky();
strcpy(h, g && g->fix_mode == 2 ? "2D fix" : "3D fix");
#else
strcpy(h, "Fix");
#endif
}
if (loc && on) snprintf(t, sizeof(t), "%ld sats", loc->satellitesCount());
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", "");
#endif
const int mv = _task->getBattMilliVolts();
const int pct = battery::percent(mv, _node_prefs ? (int)_node_prefs->low_batt_mv : 0);
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 (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);
}
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());
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.
static const MiniIcon* pageIcon(int page) {
switch (page) {
case CLOCK: return &ICON_PG_CLOCK;
case FAVOURITES: return &ICON_PG_STAR;
case STATUS: return &ICON_CHART;
case BLUETOOTH: return &ICON_PG_BT;
case ADVERT: return &ICON_PG_ADVERT;
case SETTINGS: return &ICON_PG_SETTINGS;
case MAP: return &ICON_PG_MAP;
case TOOLS: return &ICON_PG_TOOLS;
case QUICK_MSG: return &ICON_PG_MSG;
case SHUTDOWN: return &ICON_PG_POWER;
}
return nullptr;
}
int render(DisplayDriver& display) override {
char tmp[80];
int mq_delay = 0; // >0 while a selected row's name is marquee-scrolling
int anim_ms = 0; // >0 while something animates: a page turn
display.setTextSize(1);
const int lh = display.getLineHeight(); // line height at sz1
const int step = display.lineStep(); // lh + 2
// Page-indicator row: small page icons (PAGE_ICON_PX) replace the old dots. Centre and
// gap scale with the font so the band clears the header above and content
// below (identical to the old lh+4 / +6 dots layout at 1x).
const int pg_half = (PAGE_ICON_PX * statusIconScale(display) + 1) / 2; // page icons follow STATUS_ICON_SCALE
const int dots_y = statusBarH(display) + pg_half + 1; // icon-row centre, below the header
const int content_y = contentTop(display); // first content row, below the icons
const float slide = slideProgress(); // -1, or how far a page turn has got
// Title bar displaying node name (except on lock screen), status icons and battery.
// Hidden on fullscreen pages (CLOCK).
if (_page != CLOCK) {
display.setColor(DisplayDriver::LIGHT);
// The lock page has nothing on the left of the bar (its clock sits
// below it), so the status icons may use the whole row.
const int lock_reserve = _page == LOCK ? 0 : -1;
int rightEdge = renderBatteryIndicator(display, _task->getBattMilliVolts(), lock_reserve);
display.setColor(DisplayDriver::LIGHT);
if (_page != LOCK) {
const int bar_ty = (statusBarH(display) - lh) / 2; // text centred in a tall bar (STATUS_ICON_SCALE)
// The time, once the clock is set (the node name before that): the
// name is what others see, the time is what you glance at here.
char filtered_name[sizeof(_node_prefs->node_name)];
uint32_t now_ts = _rtc->getCurrentTime();
if (now_ts >= 1000000000UL) {
struct tm lt;
localTm(now_ts, _node_prefs->tz_offset_hours, lt);
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));
}
// Only show the live-power readout when APC is actually controlling power —
// not merely when the pref is set. While repeating APC is suppressed and
// power is pinned to the ceiling, so apcActive() is false and the name bar
// drops the readout (matching the "--" lock in Settings).
if (the_mesh.apcActive()) {
char pwr_buf[8];
snprintf(pwr_buf, sizeof(pwr_buf), "%ddB", (int)radio_driver.getTxPower());
int pwr_w = display.getTextWidth(pwr_buf);
display.drawTextEllipsized(0, bar_ty, rightEdge - 2 - pwr_w - 2, filtered_name);
display.drawTextRightAlign(rightEdge - 2, bar_ty, pwr_buf);
} else {
display.drawTextEllipsized(0, bar_ty, rightEdge - 2, filtered_name);
}
}
}
// ensure current page is visible (e.g. after settings change)
if (!isPageVisible(_page)) _page = navPage(_page, +1);
// curr page indicator — a row of small page icons, one per visible page, with
// the current page underlined. Hidden on CLOCK and LOCK (full screen used for
// the clock/dashboard).
if (_page != CLOCK && _page != LOCK) {
int order[(int)Count]; int n = buildVisibleOrder(order);
int curr_vis = 0;
for (int i = 0; i < n; i++) if (order[i] == _page) { curr_vis = i; break; }
const int s = statusIconScale(display); // STATUS_ICON_SCALE, or the font's
const int icon_w = PAGE_ICON_PX * s;
int pitch = icon_w + 5 * s; // comfortable spacing
if (n > 1) { // shrink to fit if many pages
int fit = (display.width() - icon_w) / (n - 1);
if (fit < pitch) pitch = fit;
}
int x = display.width() / 2 - pitch * (n - 1) / 2;
// The current page: its icon knocked out of a soft pill. During a page
// turn the pill glides over from the page being left.
int pw = icon_w + 4;
if (pw > pitch - 1) pw = pitch - 1;
int pill_x = x + pitch * curr_vis;
if (slide >= 0) {
for (int i = 0; i < n; i++)
if (order[i] == _slide_from) { pill_x = x + pitch * i + (int)((pill_x - x - pitch * i) * slide + 0.5f); break; }
}
display.setColor(DisplayDriver::LIGHT);
display.fillSoftRect(pill_x - pw / 2, dots_y - pg_half - 1, pw, pg_half * 2 + 3);
for (int i = 0; i < n; i++) {
const MiniIcon* ic = pageIcon(order[i]);
const int d2 = 2 * (x - pill_x < 0 ? pill_x - x : x - pill_x);
if (d2 <= pw - icon_w) display.setColor(DisplayDriver::DARK); // inside the pill
else if (d2 < pw + icon_w) { x += pitch; continue; } // half under the gliding pill
else display.setColor(DisplayDriver::LIGHT);
if (ic) miniIconDrawCenteredScaled(display, x, dots_y, *ic, s);
x += pitch;
}
display.setColor(DisplayDriver::LIGHT);
}
if (_page == HomePage::CLOCK) {
uint32_t unix_ts = _rtc->getCurrentTime();
if (unix_ts < 1000000000UL) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
int mid_y = display.height() / 2 - step;
display.drawTextCentered(display.width() / 2, mid_y, "! No time sync");
display.drawTextCentered(display.width() / 2, mid_y + step, "Enable GPS or");
display.drawTextCentered(display.width() / 2, mid_y + step * 2, "connect app");
} else {
drawClockPage(display, unix_ts);
}
} else if (_page == HomePage::LOCK) {
drawLockPage(display);
} else if (_page == HomePage::STATUS) {
drawStatusTiles(display, content_y);
} else if (isQuickPage(_page) && !(_page == HomePage::SHUTDOWN && _shutdown_init)) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
QRow rows[QUICK_MAX];
const int n = buildQuick(_page, rows);
int q = drawQuickPanel(display, content_y, rows, n);
if (_page == HomePage::TOOLS && _task->isTimerRunning()) q = 1000; // the timer's countdown
if (q > 0 && (mq_delay <= 0 || q < mq_delay)) mq_delay = q;
} else if (_page == HomePage::MAP) {
drawMapPage(display, content_y + 4); // the gap under the page icons
} else if (_page == HomePage::FAVOURITES) {
// Grid of pinned contacts. Layout transposes to current orientation:
// landscape → 3×2, portrait → 2×3. The selected card is filled.
// No title — node name + battery (top bar) and the page-dots indicator above
// serve as the page identity.
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
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;
const int cell_w = display.width() / cols;
const int cell_h = grid_h / rows;
const int line_h = display.getLineHeight();
if (_fav_sel >= NodePrefs::FAVOURITES_COUNT) _fav_sel = 0;
bool fav_changed = false; // a stale (gone) slot was pruned this pass → persist once after the loop
for (uint8_t i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) {
int row = i / cols;
int col = i % cols;
int cx = col * cell_w;
int cy = grid_y + row * cell_h;
bool sel = (i == _fav_sel);
const uint8_t* prefix = favSlotPrefix(i);
// Each slot a card: a soft frame round a pinned one, a dotted outline
// round a free one, filled while selected.
const int tx = cx + 1, ty = cy, tw = cell_w - 2, th = cell_h - 2;
display.setColor(DisplayDriver::LIGHT);
if (sel) { display.fillSoftRect(tx, ty, tw, th); display.setColor(DisplayDriver::DARK); }
else if (prefix) display.drawSoftRect(tx, ty, tw, th);
else {
for (int x = tx + 2; x < tx + tw - 1; x += 2) { display.fillRect(x, ty, 1, 1); display.fillRect(x, ty + th - 1, 1, 1); }
for (int y = ty + 2; y < ty + th - 1; y += 2) { display.fillRect(tx, y, 1, 1); display.fillRect(tx + tw - 1, y, 1, 1); }
}
char name[26];
uint8_t unread = 0;
bool overflow = false;
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];
ChannelDetails ch;
if (the_mesh.getChannel(ch_idx, ch) && ch.name[0]) {
// '#' marks a channel apart from a contact tile — the two share the
// grid and Enter does something different on each.
name[0] = '#';
display.translateUTF8ToBlocks(name + 1, chanctl::bareName(ch.name), sizeof(name) - 1);
unread = _task->getChannelUnread(ch_idx);
overflow = unread > 0 && _task->getChannelUnreadOverflow(ch_idx);
resolved = true;
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; last_text = e.text; break; }
}
}
} else if (prefix) {
for (int idx = 0; ; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) break;
if (memcmp(c.id.pub_key, prefix, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
display.translateUTF8ToBlocks(name, c.name, sizeof(name));
unread = _task->getDMUnread(c.id.pub_key);
overflow = unread > 0 && _task->getDMUnreadOverflow(c.id.pub_key);
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;
}
}
}
if (prefix && !resolved) {
// The pinned target is gone — prefs outlived the contact/channel list
// (e.g. a wiped /contacts3; onContactRemoved/onChannelRemoved only
// catch a live delete). Clear the stale slot so it renders as an empty
// "+" tile instead of a blank one. Persisted once after the loop.
_task->clearFavouriteSlot(i);
fav_changed = true;
}
if (resolved) {
// Reserve space for the unread badge so the name's ellipsis lands
// before it instead of underneath. Badge and name share one baseline.
int bw = unread > 0 ? display.unreadBadgeWidth(unread, overflow) + 3 : 0; // badge + 3 px gap
// A tall card (e-ink) has the name at the top and lines of detail
// under it: when it was last heard (a channel: its last post) and,
// for a contact with a position, how far away it is.
const bool tall = th >= 3 * line_h + 6;
int name_y = tall ? ty + 3 : ty + (th - line_h) / 2;
int name_max_w = tw - 6 - bw;
if (name_max_w < 6) name_max_w = 6;
int r = display.drawTextEllipsized(tx + 3, name_y, name_max_w, name, sel);
if (sel && r > 0) mq_delay = r;
if (unread > 0)
display.drawUnreadBadge(tx + tw - 3, name_y, unread, sel, overflow);
// 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;
auto detail = [&](const MiniIcon& ic, const char* text) {
if (y + line_h > ty + th - 2) return;
miniIconDraw(display, tx + 3, y, ic);
const int x = tx + 3 + ic.w * s + 2 * s;
display.drawTextEllipsized(x, y, tx + tw - 3 - x, text);
y += line_h + gap;
};
char buf[16];
const uint32_t now = rtc_clock.getCurrentTime();
if (last_ts) {
geo::fmtAgeShort(buf, sizeof(buf), now, last_ts);
detail(prefix && _task->favouriteSlotKind(i) == NodePrefs::FAV_KIND_CHANNEL ? ICON_PG_MSG : ICON_PG_CLOCK, buf);
}
int32_t mla, mlo;
if ((plat || plon) && _task->currentLocation(mla, mlo)) {
// The distance after an arrow towards it (north up).
geo::fmtDist(buf, sizeof(buf), geo::haversineKm(mla, mlo, plat, plon), _task->useImperial(), true);
detail(*ICON_ARROWS[((geo::bearingDeg(mla, mlo, plat, plon) + 22) % 360) / 45], buf);
}
}
} else {
display.drawTextCentered(tx + tw / 2, ty + (th - line_h) / 2, "+");
}
if (sel) display.setColor(DisplayDriver::LIGHT);
}
// Persist any pruned slots once, outside the loop — a render pass can clear
// several gone tiles but only one flash write is needed. Self-healing: once
// cleared, the slot is empty next frame so this can't re-fire per frame.
if (fav_changed) the_mesh.savePrefs();
if (_tile_menu.active) _tile_menu.render(display);
} else if (_page == HomePage::SHUTDOWN) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, content_y + step, "hibernating..."); // the panel's Hibernate row
}
if (slide >= 0) { // page turn: the page left behind slides out, this one in
display.slideCompose(_slide_dir * (int)(display.width() * slide + 0.5f));
anim_ms = 15;
}
if (Features::IS_EINK) {
// slow display: poll every 30 s; inbound msgs force immediate refresh via notify()
return (mq_delay > 0 && mq_delay < Features::HOME_REFRESH_MS) ? mq_delay : Features::HOME_REFRESH_MS;
}
if (_page == HomePage::CLOCK) {
bool show_sec = !_node_prefs || !_node_prefs->clock_hide_seconds;
int ret = show_sec ? 1000 : 60000;
if (anim_ms > 0 && anim_ms < ret) ret = anim_ms;
return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret;
}
int ret = 5000;
if (anim_ms > 0 && anim_ms < ret) ret = anim_ms;
return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret;
}
bool handleInput(char c) override {
// Favourites grid claims joystick UP/DOWN and inner LEFT/RIGHT; LEFT at the
// left column and RIGHT at the right column fall through to page nav so the
// user can still leave the page sideways.
if (_page == HomePage::FAVOURITES) {
// The tile menu consumes all input while open.
if (_tile_menu.active) {
auto res = _tile_menu.handleInput(c);
if (res == PopupMenu::SELECTED && _pin_target_slot >= 0) {
if (_tile_menu.selectedIndex() == 1) { // Replace
_task->pickFavouriteTarget(_pin_target_slot);
_pin_target_slot = -1;
return true;
}
_task->clearFavouriteSlot(_pin_target_slot);
the_mesh.savePrefs();
}
if (res != PopupMenu::NONE) _pin_target_slot = -1;
return true;
}
DisplayDriver* d = _task->getDisplay();
const int cols = d ? favCols(*d) : 2;
const int rows = NodePrefs::FAVOURITES_COUNT / cols;
int col = _fav_sel % cols;
int row = _fav_sel / cols;
if ((c == KEY_LEFT || c == KEY_PREV) && col > 0) { _fav_sel--; return true; }
if ((c == KEY_RIGHT || c == KEY_NEXT) && col < cols - 1) { _fav_sel++; return true; }
if (c == KEY_UP && row > 0) { _fav_sel -= cols; return true; }
if (c == KEY_DOWN && row < rows - 1) { _fav_sel += cols; return true; }
if (c == KEY_CONTEXT_MENU) {
// Filled tile → Unpin / Replace. An empty tile has nothing to offer:
// its Enter already opens the picker.
if (favSlotPrefix(_fav_sel)) {
_pin_target_slot = _fav_sel;
_tile_menu.begin("Slot options", 2);
_tile_menu.addItem("Unpin");
_tile_menu.addItem("Replace");
}
return true;
}
if (c == KEY_ENTER) {
// Filled slot → open its conversation, empty slot → in-place pin picker.
const uint8_t* pfx = favSlotPrefix(_fav_sel);
if (!pfx) { _task->pickFavouriteTarget(_fav_sel); return true; }
if (_task->favouriteSlotKind(_fav_sel) == NodePrefs::FAV_KIND_CHANNEL) {
_task->openChannelHistory(pfx[0]);
return true;
}
for (int idx = 0; ; idx++) {
ContactInfo c2;
if (!the_mesh.getContactByIdx(idx, c2)) break;
if (memcmp(c2.id.pub_key, pfx, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
// A room opens through its own entry point: posting to one needs a
// login handshake that a plain DM view would skip.
if (c2.type == ADV_TYPE_ROOM) _task->openRoomServer(c2);
else _task->openContactDM(c2);
return true;
}
}
_task->showAlert("Contact not found", 800);
return true;
}
// Edge LEFT/RIGHT and unhandled keys fall through to page nav below.
}
if (isQuickPage(_page) && _quick_edit) {
// Changing a bar: shown live, saved once on the way out.
if ((c == KEY_LEFT || c == KEY_RIGHT) && _node_prefs) {
QRow rows[QUICK_MAX];
const int n = buildQuick(_page, rows);
const QAct act = n > 0 ? rows[_quick_sel < n ? _quick_sel : n - 1].act : QA_NONE;
const int d = c == KEY_RIGHT ? 1 : -1;
if (act == QA_BRIGHTNESS) {
uint8_t& b = _node_prefs->display_brightness;
const uint8_t nb = (uint8_t)constrain((int)b + d, 0, 4);
if (nb != b) { b = nb; _quick_dirty = true; _task->applyDisplayPrefs(); }
} else if (act == QA_AUTO_ADVERT) {
const int i = constrain(AutoAdvertScreen::indexOf(_node_prefs->advert_auto_interval_sec) + d,
0, AutoAdvertScreen::OPT_COUNT - 1);
if (_node_prefs->advert_auto_interval_sec != AutoAdvertScreen::OPTS[i]) {
_node_prefs->advert_auto_interval_sec = AutoAdvertScreen::OPTS[i];
_quick_dirty = true;
}
}
return true;
}
if (c == KEY_ENTER || c == KEY_CANCEL) {
_quick_edit = false;
_task->savePrefsIfDirty(_quick_dirty);
_quick_dirty = false;
return true;
}
return true; // nothing else while editing
}
if (isQuickPage(_page)) {
QRow rows[QUICK_MAX];
const int n = buildQuick(_page, rows);
if (c == KEY_UP) { if (_quick_sel > 0) _quick_sel--; return true; }
if (c == KEY_DOWN) { if (_quick_sel + 1 < n) _quick_sel++; return true; }
if (c == KEY_ENTER && n > 0) { quickAct(_page, rows[_quick_sel < n ? _quick_sel : n - 1]); return true; }
}
if (c == KEY_LEFT || c == KEY_PREV) {
turnPage(-1);
return true;
}
if (c == KEY_NEXT || c == KEY_RIGHT) {
turnPage(+1);
return true;
}
if (c == KEY_ENTER && _page == HomePage::STATUS) {
_task->gotoStatusScreen(StatusScreen::TAB_RADIO);
return true;
}
if (c == KEY_ENTER && _page == HomePage::MAP) {
_task->gotoMapScreen();
return true;
}
if (c == KEY_ENTER && _page == HomePage::CLOCK) {
_task->gotoClockTools(); // Alarm / Timer / Stopwatch
return true;
}
if (c == KEY_CONTEXT_MENU && _page == HomePage::CLOCK) {
_task->gotoDashboardConfig();
return true;
}
if (c == KEY_CONTEXT_MENU && _page == HomePage::MAP) {
_task->quickShareMyLocation();
return true;
}
return false;
}
};