mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-11-01 10:21:59 +00:00
1765 lines
82 KiB
C++
1765 lines
82 KiB
C++
#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 splash's slanted lettering, as the wide
|
||
// panels' big clock: as wide as the panel allows, the two lines no more than
|
||
// ~45% of its height (the date and the month go under them). 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), h12 ? "%d" : "%02d", hh);
|
||
snprintf(mbuf, sizeof(mbuf), "%02d", ti->tm_min);
|
||
|
||
int sc = (d.height() * 45 / 100) / (2 * lettering::LOGO_H);
|
||
while (sc > 1 && info::letteringW("00", sc, sc + 1) > d.width() - 2 * sc) sc--;
|
||
if (sc < 1) sc = 1;
|
||
const int h = lettering::LOGO_H * sc, gap = sc + 1;
|
||
int y = top_y;
|
||
info::lettering(d, cx - info::letteringW(hbuf, sc, gap) / 2, y, hbuf, sc, gap); y += h + 2 * sc;
|
||
info::lettering(d, cx - info::letteringW(mbuf, sc, gap) / 2, y, mbuf, sc, gap); y += h + sc;
|
||
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->isBluetoothEnabled();
|
||
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. (Not a plain isConnected(): a USB port makes that 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(UIColor::primary_txt);
|
||
if (sel) { d.fillSoftRect(m - 3, y - 1, W - 2 * m + 6, lh + 1); d.setColor(UIColor::window_bkg); }
|
||
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(UIColor::primary_txt);
|
||
}
|
||
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->isBluetoothEnabled()) _task->disableBluetooth(); else _task->enableBluetooth();
|
||
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(UIColor::primary_txt);
|
||
|
||
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 t = display.sepH(); // outline: 1px, 2px on a doubled panel
|
||
const int bm = 2 * t; // inner margin: the outline plus as much gap
|
||
battLeftX = display.width() - iconW - 3;
|
||
// The outline with its corner pixels left out, softly rounded.
|
||
display.drawSoftRect(battLeftX, 0, iconW, iconH);
|
||
// 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 * t, 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->isBluetoothEnabled() && _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) { }
|
||
|
||
void showBluetoothPage() {
|
||
_page = BLUETOOTH;
|
||
_quick_sel = 0;
|
||
}
|
||
|
||
void showGpsPage() {
|
||
_page = STATUS;
|
||
_quick_sel = 0;
|
||
}
|
||
|
||
// 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(UIColor::primary_txt);
|
||
{
|
||
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(UIColor::primary_txt);
|
||
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, display.sepH()); // 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(UIColor::primary_txt);
|
||
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(UIColor::window_bkg);
|
||
d.fillRect(tx - 2, ty - 1, tw + 4, lh + 2);
|
||
d.setColor(UIColor::primary_txt);
|
||
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(UIColor::primary_txt);
|
||
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;
|
||
const int t = d.sepH();
|
||
d.fillRect(x0, bar_y, fill, t);
|
||
for (int x = x0 + fill + (fill & 1) * t; x < x0 + bw; x += 2 * t) d.fillRect(x, bar_y, t, t);
|
||
}
|
||
// 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(UIColor::primary_txt);
|
||
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(UIColor::window_bkg);
|
||
d.drawTextCentered(W / 2, pill_y + 2, pill);
|
||
d.setColor(UIColor::primary_txt);
|
||
}
|
||
|
||
// 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(UIColor::primary_txt);
|
||
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(UIColor::primary_txt);
|
||
// 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(UIColor::primary_txt);
|
||
|
||
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.
|
||
const int pad = display.sepH(); // the pill's margin round the icon
|
||
int pw = icon_w + 4 * pad;
|
||
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(UIColor::primary_txt);
|
||
display.fillSoftRect(pill_x - pw / 2, dots_y - pg_half - pad, pw, pg_half * 2 + 1 + 2 * pad);
|
||
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(UIColor::window_bkg); // inside the pill
|
||
else if (d2 < pw + icon_w) { x += pitch; continue; } // half under the gliding pill
|
||
else display.setColor(UIColor::primary_txt);
|
||
if (ic) miniIconDrawCenteredScaled(display, x, dots_y, *ic, s);
|
||
x += pitch;
|
||
}
|
||
display.setColor(UIColor::primary_txt);
|
||
}
|
||
|
||
if (_page == HomePage::CLOCK) {
|
||
uint32_t unix_ts = _rtc->getCurrentTime();
|
||
if (unix_ts < 1000000000UL) {
|
||
display.setColor(UIColor::primary_txt);
|
||
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(UIColor::primary_txt);
|
||
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(UIColor::primary_txt);
|
||
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(UIColor::primary_txt);
|
||
if (sel) { display.fillSoftRect(tx, ty, tw, th); display.setColor(UIColor::window_bkg); }
|
||
else if (prefix) display.drawSoftRect(tx, ty, tw, th);
|
||
else {
|
||
const int t = display.sepH(); // dot size: 2px on a doubled panel
|
||
for (int x = tx + 2 * t; x < tx + tw - t; x += 2 * t) { display.fillRect(x, ty, t, t); display.fillRect(x, ty + th - t, t, t); }
|
||
for (int y = ty + 2 * t; y < ty + th - t; y += 2 * t) { display.fillRect(tx, y, t, t); display.fillRect(tx + tw - t, y, t, t); }
|
||
}
|
||
|
||
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 = MAX_ANON_CONTACTS; ; 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 ? UIColor::window_bkg : UIColor::primary_txt);
|
||
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 ? UIColor::window_bkg : UIColor::primary_txt); // 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(UIColor::primary_txt);
|
||
}
|
||
// 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(UIColor::primary_txt);
|
||
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 = MAX_ANON_CONTACTS; ; 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;
|
||
}
|
||
};
|