feat(l1): Home quick panels, map page with live data, quieter status bar

- Icon pages become quick panels: two or three rows (Settings, Messages,
  Bluetooth, Advert, Tools, Power) inset from the edges, the last one
  opening the full screen. Brightness and auto-advert are edited in place
  (Enter, Left/Right, Enter/Back saves once).
- Tools panel shows the two tools used last with their live state.
- Map page: preview on the left, GPS / trail / live / target on the right.
- Favourites as soft cards; Status tile with a battery spark line.
- Status bar without boxes or blinking; BT only when connected, GPS
  search glyph without a fix.
- Redrawn mini icons (advert waves, cog, wrench, messages); big hovering
  icons removed. Tools list is text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-02 10:23:58 +02:00
co-authored by Claude Opus 5.5
parent 6d8e56fbd0
commit 0f635c0171
9 changed files with 521 additions and 582 deletions
-2
View File
@@ -50,13 +50,11 @@ namespace Features {
#if defined(EINK_DISPLAY_MODEL)
static constexpr bool IS_EINK = true;
static constexpr bool BLINK_INDICATORS = false; // e-ink avoids high-rate redraws
static constexpr bool CLOCK_HIDE_SECONDS_DEFAULT = true; // pref starting value
static constexpr unsigned HOME_REFRESH_MS = 30000; // slow display polls less
static constexpr unsigned LOCKSCREEN_REFRESH_MS = 30000;
#else
static constexpr bool IS_EINK = false;
static constexpr bool BLINK_INDICATORS = true;
static constexpr bool CLOCK_HIDE_SECONDS_DEFAULT = false;
static constexpr unsigned HOME_REFRESH_MS = 1000;
static constexpr unsigned LOCKSCREEN_REFRESH_MS = 1000;
@@ -184,6 +184,10 @@ public:
return pos;
}
// All channels' entries, newest first (j = 0): ring position for chAtPos().
int chHistCount() const { return _hist_count; }
int chHistPosNewest(int j) const { return (_hist_head + _hist_count - 1 - j) % CH_HIST_MAX; }
// count history entries for a specific channel
int histCountForChannel(int ch_idx) const {
int n = 0;
@@ -8,16 +8,20 @@ class AutoAdvertScreen : public UIScreen {
NodePrefs* _prefs;
bool _dirty;
public:
// The intervals on offer, shared with the Home Advert panel.
static const int OPT_COUNT = 8;
static const uint32_t OPTS[OPT_COUNT];
static const char* OPT_LABELS[OPT_COUNT];
int currentIdx() const {
static int indexOf(uint32_t sec) {
for (int i = 0; i < OPT_COUNT; i++)
if (OPTS[i] == _prefs->advert_auto_interval_sec) return i;
if (OPTS[i] == sec) return i;
return 0;
}
private:
int currentIdx() const { return indexOf(_prefs->advert_auto_interval_sec); }
public:
AutoAdvertScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
@@ -690,7 +690,7 @@ public:
if (!right[0]) snprintf(right, sizeof(right), "?"); // unknown / RTC not synced
} else {
if (e.dist_km >= 0.0f) geo::fmtDist(right, sizeof(right), e.dist_km, useImperial());
else strncpy(right, "?GPS", sizeof(right));
else strcpy(right, "-"); // no fix of ours or theirs
}
if (right[0]) display.drawTextRightAlign(display.width() - reserve - 2, y, right);
});
@@ -330,6 +330,8 @@ public:
_last_rx = rx;
}
const info::History<32>& battHistory() const { return _batt; }
// Contacts heard from in the last hour (cached for 10 s: it walks them all).
static int heardLastHour() {
static unsigned long at = 0;
+58 -59
View File
@@ -4,14 +4,13 @@
//
// The flat 10-item list grew crowded, so tools are grouped into collapsible
// sections (Location / Comms / System) via the shared AccordionList helper —
// the same fold-in-place model as Settings. Each row carries a mini-icon;
// section headers show their cog/marker plus a fold indicator.
// the same fold-in-place model as Settings. Rows are text only; section
// headers carry a fold mark.
#include "AccordionList.h"
class ToolsScreen : public UIScreen {
UITask* _task;
public:
enum Action {
ACT_NEARBY, ACT_LIVESHARE, ACT_TRAIL, ACT_LOCATOR, ACT_COMPASS,
ACT_BOT, ACT_AUTOADVERT, ACT_REPEATER, ACT_ADMIN,
@@ -20,9 +19,45 @@ class ToolsScreen : public UIScreen {
, ACT_GPIO
#endif
};
struct Tool { const char* label; const MiniIcon* icon; Action action; };
struct Tool { const char* label; Action action; };
struct Section { const char* name; const Tool* tools; uint8_t count; };
// The two tools used last, newest first, for the Home Tools panel. Kept in
// RAM only: a reboot starts again from Nodes and Live Share.
static const int RECENT_MAX = 2;
static Action s_recent[RECENT_MAX];
static const char* labelOf(Action a) {
for (int i = 0; i < SECTION_COUNT; i++)
for (int j = 0; j < SECTIONS[i].count; j++)
if (SECTIONS[i].tools[j].action == a) return SECTIONS[i].tools[j].label;
return "";
}
// Opens a tool and moves it to the front of the recent list.
static void run(UITask* task, Action a) {
if (s_recent[0] != a) { s_recent[1] = s_recent[0]; s_recent[0] = a; }
switch (a) {
case ACT_NEARBY: task->gotoNearbyScreen(); break;
case ACT_LIVESHARE: task->gotoLiveShareScreen(); break;
case ACT_TRAIL: task->gotoTrailScreen(); break;
case ACT_LOCATOR: task->gotoLocatorScreen(); break;
case ACT_COMPASS: task->gotoCompassScreen(); break;
case ACT_BOT: task->gotoBotScreen(); break;
case ACT_AUTOADVERT: task->gotoAutoAdvertScreen(); break;
case ACT_REPEATER: task->gotoRepeaterScreen(); break;
case ACT_ADMIN: task->pickAdminTarget(); break; // Admin is remote-only: pick a node first
case ACT_CLOCK: task->gotoClockTools(); break;
case ACT_RINGTONE: task->gotoRingtoneEditor(); break;
#if defined(PIN_GPIO1)
case ACT_GPIO: task->gotoGpioScreen(); break;
#endif
}
}
private:
UITask* _task;
static const Tool LOCATION_TOOLS[];
static const Tool COMMS_TOOLS[];
static const Tool SYSTEM_TOOLS[];
@@ -31,41 +66,6 @@ class ToolsScreen : public UIScreen {
AccordionList _acc;
// Fixed icon gutter so labels line up whether or not a row has a glyph. Sized
// for the widest icon (the 7px cog) at the current font scale.
static int gutter(DisplayDriver& d) { return 7 * miniIconScale(d) + 2; }
// Was hand-rolling its own vertical centring against lineStep() (line height
// + the 2px inter-row gap) instead of just the line height itself -- 2px too
// much slack pushed a nearly-full-height icon (e.g. the 7px cog) low enough
// to clip against the row below, which is what "don't fit visually" meant.
// miniIconDraw() (icons.h) already centres correctly against getLineHeight()
// alone -- the same call drawAckGlyph uses for the message-list checkmark --
// so just delegate to it instead of keeping a second, subtly-wrong copy.
static void drawIcon(DisplayDriver& d, int x, int y, const MiniIcon* ic) {
if (!ic) return;
miniIconDraw(d, x, y, *ic);
}
void dispatch(Action a) {
switch (a) {
case ACT_NEARBY: _task->gotoNearbyScreen(); break;
case ACT_LIVESHARE: _task->gotoLiveShareScreen(); break;
case ACT_TRAIL: _task->gotoTrailScreen(); break;
case ACT_LOCATOR: _task->gotoLocatorScreen(); break;
case ACT_COMPASS: _task->gotoCompassScreen(); break;
case ACT_BOT: _task->gotoBotScreen(); break;
case ACT_AUTOADVERT: _task->gotoAutoAdvertScreen(); break;
case ACT_REPEATER: _task->gotoRepeaterScreen(); break;
case ACT_ADMIN: _task->pickAdminTarget(); break; // Admin is remote-only: pick a node first
case ACT_CLOCK: _task->gotoClockTools(); break;
case ACT_RINGTONE: _task->gotoRingtoneEditor(); break;
#if defined(PIN_GPIO1)
case ACT_GPIO: _task->gotoGpioScreen(); break;
#endif
}
}
public:
ToolsScreen(UITask* task) : _task(task) {}
@@ -82,22 +82,19 @@ public:
drawScreenHeader(display, "Tools");
const int cw = display.getCharWidth();
const int g = gutter(display);
_acc.render(display,
// Section header: disclosure mark + name
[&](int sec, int y, bool sel, int reserve, bool collapsed) {
drawRowSelection(display, y, sel, reserve);
drawDisclosure(display, 2, y, !collapsed);
display.setCursor(2 + 2 * cw, y); // the mark stands in for an icon, as in Settings
display.setCursor(2 + 2 * cw, y); // after the disclosure mark, as in Settings
display.print(SECTIONS[sec].name);
},
// Item: indented "<icon> Label"
// Item: its label, under its section's name
[&](int sec, int item, int y, bool sel, int reserve) {
drawRowSelection(display, y, sel, reserve);
const int icon_x = 2 + cw + 2; // align item icons under the header icon
drawIcon(display, icon_x, y, SECTIONS[sec].tools[item].icon);
display.setCursor(icon_x + g, y);
display.setCursor(2 + 2 * cw, y);
display.print(SECTIONS[sec].tools[item].label);
});
return 500;
@@ -108,7 +105,7 @@ public:
switch (_acc.handleInput(c)) {
case AccordionList::ACTIVATED: {
const AccordionList::Row& r = _acc.selected();
dispatch(SECTIONS[r.sec].tools[r.item].action);
run(_task, SECTIONS[r.sec].tools[r.item].action);
return true;
}
case AccordionList::HANDLED: return true;
@@ -118,24 +115,26 @@ public:
}
};
ToolsScreen::Action ToolsScreen::s_recent[ToolsScreen::RECENT_MAX] = { ACT_NEARBY, ACT_LIVESHARE };
const ToolsScreen::Tool ToolsScreen::LOCATION_TOOLS[] = {
{ "Nodes", &ICON_MAP_CONTACT, ACT_NEARBY },
{ "Live Share", &ICON_GPS, ACT_LIVESHARE },
{ "Trail", &ICON_TRAIL, ACT_TRAIL },
{ "Locator", &ICON_MAP_WAYPOINT, ACT_LOCATOR },
{ "Compass", &ICON_MAP_NORTH, ACT_COMPASS },
{ "Nodes", ACT_NEARBY },
{ "Live Share", ACT_LIVESHARE },
{ "Trail", ACT_TRAIL },
{ "Locator", ACT_LOCATOR },
{ "Compass", ACT_COMPASS },
};
const ToolsScreen::Tool ToolsScreen::COMMS_TOOLS[] = {
{ "Remote Bot", &ICON_BOT, ACT_BOT },
{ "Auto-Advert", &ICON_ADVERT, ACT_AUTOADVERT },
{ "Repeater", &ICON_REPEATER, ACT_REPEATER },
{ "Admin", &ICON_KEY, ACT_ADMIN },
{ "Remote Bot", ACT_BOT },
{ "Auto-Advert", ACT_AUTOADVERT },
{ "Repeater", ACT_REPEATER },
{ "Admin", ACT_ADMIN },
};
const ToolsScreen::Tool ToolsScreen::SYSTEM_TOOLS[] = {
{ "Clock Tools", &ICON_ALARM, ACT_CLOCK },
{ "Ringtone Editor", &ICON_NOTE, ACT_RINGTONE },
{ "Clock Tools", ACT_CLOCK },
{ "Ringtone Editor", ACT_RINGTONE },
#if defined(PIN_GPIO1)
{ "GPIO", &ICON_PINS, ACT_GPIO },
{ "GPIO", ACT_GPIO },
#endif
};
const ToolsScreen::Section ToolsScreen::SECTIONS[] = {
+411 -179
View File
@@ -57,12 +57,6 @@
#include "../ui-core/Lettering.h" // boot splash wordmark + lettering (shared with the L2)
#include "GfxUtils.h" // gfx::drawLine — connects trail points on the Home map preview
// Blinking status indicators: on for the first half of a 4 s cycle, but e-ink
// can't repaint fast enough to blink, so it shows them steadily.
static inline bool blinkOn() {
return Features::BLINK_INDICATORS ? ((millis() % 4000) < 2000) : true;
}
// Boot splash, as on the L2 (ui-lvgl/Splash.h, same lettering from
// ui-core/Lettering.h): the MeshCore wordmark rising into place, "solo" in its
// lettering once it has landed, the Solo version, upstream version + build date, and three dots
@@ -349,6 +343,7 @@ class HomeScreen : public UIScreen {
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;
@@ -357,6 +352,269 @@ class HomeScreen : public UIScreen {
}
}
unsigned long _advert_sent_ms = 0; // last advert sent from this page (0: none yet)
// ── Quick panels: an icon page as two or three 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
};
uint8_t _quick_sel = 0;
bool _quick_edit = false; // the selected row is being changed with Left/Right
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 two 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[4];
int nc = 0;
for (int j = 0; j < h.dmHistCount() && nc < 2; 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 + 2; 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", cd.name[0] == '#' ? cd.name + 1 : 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;
};
if (page == SETTINGS) {
#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
add("All settings", Q_MORE, QA_MORE, 0);
} else if (page == QUICK_MSG) {
n = recentChats(rows, 2);
add("All messages", Q_MORE, QA_MORE, 0);
} 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) {
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));
}
add("All tools", Q_MORE, QA_MORE, 0);
} 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",
battMvToPercent(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); 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;
@@ -494,24 +752,24 @@ class HomeScreen : public UIScreen {
// 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;
// Blinks while GPS is napping between duty-cycle wakes -- same convention
// the background-mode icons below already use for "running, but not busy
// right this instant".
bool gps_napping = _sensors && _sensors->isGpsDutySleeping();
bool mute_on = false;
#ifdef PIN_BUZZER
mute_on = _task->isBuzzerQuiet();
#endif
struct Sicon { bool active; const MiniIcon* icon; bool boxed; bool blink; };
// 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[] = {
{ _task->isSerialEnabled(), &ICON_BLUETOOTH, _task->isSerialEnabled() && _task->isBLEConnected(), false },
{ gps_on, &ICON_GPS, gps_on && loc->isValid(), gps_napping },
{ _node_prefs && _node_prefs->alarm_on, &ICON_ALARM, true, false },
{ mute_on, &ICON_MUTE, true, false },
{ _node_prefs && _node_prefs->advert_auto_interval_sec > 0, &ICON_ADVERT, true, true },
{ _task->trail().isActive(), &ICON_TRAIL, true, true },
{ _node_prefs && _node_prefs->loc_share_enabled, &ICON_MAP_CONTACT, true, true },
{ _node_prefs && _node_prefs->client_repeat, &ICON_REPEATER, true, true },
{ _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;
@@ -520,10 +778,7 @@ class HomeScreen : public UIScreen {
if (!s.active) continue;
int ix = x - ind - ind_gap;
if (ix < name_min) break; // out of room — drop this + all lower priority
if (!s.blink || blinkOn()) {
if (s.boxed) drawBoxedIcon(display, ix, ind, ind_h, *s.icon);
else drawSlotIcon(display, ix, ind, ind_h, *s.icon);
}
drawSlotIcon(display, ix, ind, ind_h, *s.icon);
x = ix;
}
return x;
@@ -708,6 +963,71 @@ public:
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.
void drawMapPage(DisplayDriver& d, int top) {
const int W = d.width(), H = d.height(), lh = d.getLineHeight();
const int s = miniIconScale(d);
const int pw = W / 2, ph = H - top; // 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);
}
// The column: four rows, always, like the Status tiles.
const int cx = pw + 3, cw = W - cx;
const int row_h = ph / 4;
int y = 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 Status page: four tiles split by dotted rules, each one subject's
// headline (icon + main value) over a detail line. Enter opens the Status
// screen with all of it.
@@ -751,10 +1071,10 @@ public:
#endif
}
if (loc && on) snprintf(t, sizeof(t), "%ld sats", loc->satellitesCount());
tile(1, 0, &ICON_PG_GPS, -1, h, t);
tile(1, 0, &ICON_GPS, -1, h, t);
}
#else
tile(1, 0, &ICON_PG_GPS, -1, "No GPS", "");
tile(1, 0, &ICON_GPS, -1, "No GPS", "");
#endif
const int mv = _task->getBattMilliVolts();
@@ -762,6 +1082,10 @@ public:
snprintf(h, sizeof(h), "%d%%", pct);
snprintf(t, sizeof(t), "%d.%02d V%s", mv / 1000, (mv % 1000) / 10, board.isExternalPowered() ? " USB" : "");
tile(0, 1, nullptr, pct / 100.0f, h, t);
if (StatusScreen* st = (StatusScreen*)_task->statusScreen()) { // the battery's trend beside its %
const int x0 = 1 + info::batteryW(d) + 2 * s + d.getTextWidth(h) + 3 * s, x1 = mid_x - 3;
if (x1 - x0 >= 12) info::spark(d, x0, mid_y + 2, x1 - x0, lh - 1, st->battHistory(), 20);
}
snprintf(h, sizeof(h), "%d nodes", the_mesh.getNumContacts());
snprintf(t, sizeof(t), "%d in 1 h", StatusScreen::heardLastHour());
@@ -789,7 +1113,7 @@ public:
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 hovering page icon, a page turn
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
@@ -1033,97 +1357,19 @@ public:
display.print(hint);
} else if (_page == HomePage::STATUS) {
drawStatusTiles(display, content_y);
} else if (_page == HomePage::BLUETOOTH) {
} else if (isQuickPage(_page) && !(_page == HomePage::SHUTDOWN && _shutdown_init)) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
anim_ms = drawHoverIcon(display, display.width() / 2, content_y, BIG_BLUETOOTH, !_task->isSerialEnabled());
const int text_y = content_y + BIG_BLUETOOTH.h + HOVER_GAP;
// 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.)
const bool waiting_for_pair = _task->isSerialEnabled() && !_task->isBLEConnected() && the_mesh.getBLEPin() != 0;
if (waiting_for_pair && !display.isLandscape()) {
char pin_buf[16];
snprintf(pin_buf, sizeof(pin_buf), "PIN: %d", the_mesh.getBLEPin());
display.drawTextCentered(display.width() / 2, text_y, pin_buf);
} else if (waiting_for_pair) { // the pairing PIN takes the title's place
char pin_buf[16];
snprintf(pin_buf, sizeof(pin_buf), "PIN: %d", the_mesh.getBLEPin());
display.drawTextCentered(display.width() / 2, text_y, pin_buf);
} else {
// Each icon page carries just its title; Enter acting on it is the same everywhere.
display.drawTextCentered(display.width() / 2, text_y, "Bluetooth");
}
} else if (_page == HomePage::ADVERT) {
display.setColor(DisplayDriver::LIGHT);
anim_ms = drawHoverIcon(display, display.width() / 2, content_y, BIG_ADVERT);
display.drawTextCentered(display.width() / 2, content_y + BIG_ADVERT.h + HOVER_GAP, "Advert");
} else if (_page == HomePage::SETTINGS) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
anim_ms = drawHoverIcon(display, display.width() / 2, content_y, BIG_SETTINGS);
display.drawTextCentered(display.width() / 2, content_y + BIG_SETTINGS.h + HOVER_GAP, "Settings");
QRow rows[3];
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) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
// Mini-map preview filling the page, with one status line at the bottom.
int info_y = display.height() - step;
int area_h = info_y - content_y - 2;
bool drew = drawMapPreview(display, 2, content_y, display.width() - 4, area_h);
char left[20], right[16] = {0};
uint32_t now_m = rtc_clock.getCurrentTime();
LiveTrackStore& lt = _task->liveTrack();
int trk = lt.active(now_m);
// Fix state lives in the top-bar GPS icon. Track count plus an arrow +
// distance (to the active target, else the nearest live-tracked
// contact) share this one status line.
snprintf(left, sizeof(left), "Track:%d", trk);
float nearest_km = statusDistanceKm();
if (nearest_km >= 0.0f) geo::fmtDist(right, sizeof(right), nearest_km, _task->useImperial());
display.setColor(DisplayDriver::LIGHT);
if (!drew)
display.drawTextCentered(display.width() / 2, content_y + area_h / 2, "No GPS / no trail");
if (right[0]) {
// Manual layout (not drawTextCentered) so the arrow mini-icon sits
// inline between the two text runs.
const int s = miniIconScale(display);
const int gap = 3;
int lw = display.getTextWidth(left);
int iw = ICON_MAP_ARROW.w * s;
int rw = display.getTextWidth(right);
int x = display.width() / 2 - (lw + gap + iw + gap + rw) / 2;
display.setCursor(x, info_y);
display.print(left);
miniIconDrawTop(display, x + lw + gap, info_y + (lh - ICON_MAP_ARROW.h * s) / 2, ICON_MAP_ARROW);
display.setCursor(x + lw + gap + iw + gap, info_y);
display.print(right);
} else {
display.drawTextCentered(display.width() / 2, info_y, left);
}
} else if (_page == HomePage::TOOLS) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
anim_ms = drawHoverIcon(display, display.width() / 2, content_y, BIG_TOOLS);
display.drawTextCentered(display.width() / 2, content_y + BIG_TOOLS.h + HOVER_GAP, "Tools");
} else if (_page == HomePage::QUICK_MSG) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
const int ix = (display.width() - BIG_MESSAGES.w) / 2;
anim_ms = drawHoverIcon(display, display.width() / 2, content_y, BIG_MESSAGES);
// Unread count as the usual pill on the bubble's top-right corner.
int total_unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount();
if (total_unread > 0) {
int bw = display.unreadBadgeWidth(total_unread, _task->getAnyUnreadOverflow());
int bx = ix + BIG_MESSAGES.w + bw / 2;
display.setColor(DisplayDriver::DARK); // a dark ring so it reads over the bubble's border
const int by = content_y - hoverLift(display); // rides on the hovering bubble
display.fillRect(bx - bw - 1, by + 1, bw + 2, lh + 2);
display.drawUnreadBadge(bx, by + 2, total_unread, false, _task->getAnyUnreadOverflow());
}
display.drawTextCentered(display.width() / 2, content_y + BIG_MESSAGES.h + HOVER_GAP, "Messages");
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. Selected tile inverts via drawSelectionRow.
// 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);
@@ -1131,9 +1377,8 @@ public:
const int cols = display.isLandscape() ? 3 : 2;
const int rows = NodePrefs::FAVOURITES_COUNT / cols;
const int margin = 2;
const int grid_y = content_y + margin;
const int grid_h = display.height() - grid_y - margin;
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();
@@ -1147,9 +1392,18 @@ public:
int cx = col * cell_w;
int cy = grid_y + row * cell_h;
bool sel = (i == _fav_sel);
display.drawSelectionRow(cx, cy, cell_w - 1, cell_h - 1, 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;
@@ -1194,16 +1448,15 @@ public:
// 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
int name_y = cy + (cell_h - line_h) / 2;
int name_max_w = cell_w - 4 - bw;
int name_y = 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(cx + 2, name_y, name_max_w, name, sel);
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(cx + cell_w - 2, name_y, unread, sel, overflow);
display.drawUnreadBadge(tx + tw - 3, name_y, unread, sel, overflow);
} else {
int plus_y = cy + (cell_h - line_h) / 2;
display.drawTextCentered(cx + cell_w / 2, plus_y, "+");
display.drawTextCentered(tx + tw / 2, ty + (th - line_h) / 2, "+");
}
if (sel) display.setColor(DisplayDriver::LIGHT);
}
@@ -1215,39 +1468,23 @@ public:
} else if (_page == HomePage::SHUTDOWN) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
if (_shutdown_init) {
display.drawTextCentered(display.width() / 2, content_y + step, "hibernating...");
} else {
anim_ms = drawHoverIcon(display, display.width() / 2, content_y, BIG_POWER);
const int text_y = content_y + BIG_POWER.h + HOVER_GAP;
const int lh1 = display.getLineHeight();
if (text_y + lh1 <= display.height())
display.drawTextCentered(display.width() / 2, text_y, "Hibernate");
}
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;
}
bool auto_adv = _node_prefs && _node_prefs->advert_auto_interval_sec > 0;
// Any blinking status-bar indicator needs a 1 s refresh to animate evenly —
// but the status bar (and its icons) is hidden on the CLOCK page, so don't
// pay the 1 s cadence there for icons that aren't drawn.
bool repeating = _node_prefs && _node_prefs->client_repeat;
bool loc_sharing = _node_prefs && _node_prefs->loc_share_enabled;
bool need_blink = (_page != HomePage::CLOCK) &&
(auto_adv || _task->trail().isActive() || repeating || loc_sharing);
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 = need_blink ? 1000 : (show_sec ? 1000 : 60000);
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 = need_blink ? 1000 : 5000;
int ret = 5000;
if (anim_ms > 0 && anim_ms < ret) ret = anim_ms;
return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret;
}
@@ -1320,6 +1557,34 @@ public:
// 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[3];
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;
b = (uint8_t)constrain((int)b + d, 0, 4);
_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);
_node_prefs->advert_auto_interval_sec = AutoAdvertScreen::OPTS[i];
}
return true;
}
if (c == KEY_ENTER || c == KEY_CANCEL) { _quick_edit = false; the_mesh.savePrefs(); return true; }
return true; // nothing else while editing
}
if (isQuickPage(_page)) {
QRow rows[3];
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;
@@ -1328,47 +1593,14 @@ public:
turnPage(+1);
return true;
}
if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
if (_task->isSerialEnabled()) { // toggle Bluetooth on/off
_task->disableSerial();
} else {
_task->enableSerial();
}
return true;
}
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
_task->notify(UIEventType::ack);
if (the_mesh.advert()) {
_task->showAlert("Advert sent", 1000);
} else {
_task->showAlert("Advert failed", 1000);
}
return true;
}
if (c == KEY_ENTER && _page == HomePage::STATUS) {
_task->gotoStatusScreen(StatusScreen::TAB_RADIO);
return true;
}
if (c == KEY_ENTER && _page == HomePage::SETTINGS) {
_task->gotoSettingsScreen();
return true;
}
if (c == KEY_ENTER && _page == HomePage::MAP) {
_task->gotoMapScreen();
return true;
}
if (c == KEY_ENTER && _page == HomePage::TOOLS) {
_task->gotoToolsScreen();
return true;
}
if (c == KEY_ENTER && _page == HomePage::QUICK_MSG) {
_task->gotoMessagesScreen();
return true;
}
if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) {
_shutdown_init = true; // need to wait for button to be released
return true;
}
if (c == KEY_ENTER && _page == HomePage::CLOCK) {
_task->gotoClockTools(); // Alarm / Timer / Stopwatch
return true;
@@ -3094,17 +3326,17 @@ bool UITask::hasGPS() {
return false;
}
void UITask::toggleGPS() {
if (_node_prefs) applyGpsState(_node_prefs->gps_enabled == 0);
void UITask::toggleGPS(bool announce) {
if (_node_prefs) applyGpsState(_node_prefs->gps_enabled == 0, announce);
}
// Sets GPS to an absolute state (vs. toggleGPS()'s flip) -- shared by the
// Home-page manual toggle and the bot's !gps on/off command, which needs to
// set a specific state rather than flip whatever it currently is.
void UITask::applyGpsState(bool on) {
void UITask::applyGpsState(bool on, bool announce) {
if (!_core->setGpsEnabled(on)) return; // no GPS on this board
notify(UIEventType::ack);
showAlert(on ? "GPS: Enabled" : "GPS: Disabled", 800);
if (announce) showAlert(on ? "GPS: Enabled" : "GPS: Disabled", 800);
_next_refresh = 0;
}
+5 -2
View File
@@ -242,6 +242,8 @@ private:
void renderAlertOverlay();
public:
// Lock now, from the Home Power panel (unlocking stays the gesture / PIN prompt).
void lockScreen() { if (!_locked) toggleLock(); }
// A new screen PIN (see ui-core/ScreenLock.h); "" clears it. The caller saves the prefs.
void setNodeLockPassword(const char* plain);
bool passwordLockEnabled() const;
@@ -331,6 +333,7 @@ public:
void gotoMapScreen(); // opens the Trail screen directly in its Map view
void gotoCompassScreen();
void gotoStatusScreen(uint8_t tab); // StatusScreen::Tab
UIScreen* statusScreen() const { return status_screen; } // its sample histories feed the Home tiles
void gotoRepeaterScreen();
void gotoGpioScreen(); // no-op on boards without user GPIO pins (see PIN_GPIO1)
void gotoClockTools(); // Alarm / Timer / Stopwatch (from the home Clock page)
@@ -436,8 +439,8 @@ public:
int getBuzzerMode(); // 0=ON, 1=OFF, 2=Auto
bool getGPSState();
bool hasGPS(); // true if this board exposes a toggleable GPS (distinct from GPS being off)
void toggleGPS();
void applyGpsState(bool on); // shared by toggleGPS() and botSetGPS()
void toggleGPS(bool announce = true); // announce: the "GPS: Enabled" toast (off where a switch shows it)
void applyGpsState(bool on, bool announce = true); // shared by toggleGPS() and botSetGPS()
void botSetGPS(bool on) override;
void botBuzz(int seconds) override;
// User GPIO (!gpio1..!gpio4 + Tools > GPIO screen). idx is 1-4. Bodies are
+33 -336
View File
@@ -7,8 +7,7 @@
// ── Scalable mini-icons ──────────────────────────────────────────────────────
// Small procedural glyphs (delivery markers, etc.) authored on a 1× pixel grid
// and scaled to the current font, so they stay legible on large-font layouts
// (e.g. landscape e-ink renders text at 2×). Distinct from the big page icons
// further down, which are fixed-size 32 px glyphs drawn via bigIconDraw().
// (e.g. landscape e-ink renders text at 2×).
//
// To add a mini-icon:
// 1. Draw it as ASCII-art rows, one string per row (width ≤ 8): any char
@@ -233,13 +232,12 @@ MINI_ICON(ICON_BLUETOOTH, 5, // ᛒ bluetooth rune
packRow("#.#.#"),
packRow("..##."),
packRow("..#.."));
MINI_ICON(ICON_ADVERT, 6, // broadcast mast + radiating waves (auto-advert)
packRow("#....#"),
packRow(".#..#."),
packRow("..##.."),
packRow("..##.."),
packRow(".####."),
packRow(".####."));
MINI_ICON(ICON_ADVERT, 7, // ((•)) a dot sending waves both ways: advert
packRow(".#...#."),
packRow("#.###.#"),
packRow("#.###.#"),
packRow("#.###.#"),
packRow(".#...#."));
MINI_ICON(ICON_ALARM, 5, // bell — an alarm is armed
packRow("..#.."),
@@ -264,12 +262,18 @@ MINI_ICON(ICON_REPEATER, 6, // » double chevron — relaying/forwarding (repe
packRow(".#..#."),
packRow("#..#.."));
MINI_ICON(ICON_GPS, 5, // reticle — GPS fix status (boxed = fix, plain = searching)
MINI_ICON(ICON_GPS, 5, // reticle with its dot: GPS has a fix
packRow(".###."),
packRow("#...#"),
packRow("#.#.#"),
packRow("#...#"),
packRow(".###."));
MINI_ICON(ICON_GPS_SEARCH, 5, // the reticle broken, no dot: GPS on, still searching
packRow(".#.#."),
packRow("#...#"),
packRow("....."),
packRow("#...#"),
packRow(".#.#."));
// Tools-menu glyphs (auto-reply bot, ringtone editor, diagnostics, system).
MINI_ICON(ICON_BOT, 5, // robot head: antenna + eyes + grille (auto-reply bot)
@@ -294,14 +298,6 @@ MINI_ICON(ICON_CHART, 5, // ascending bars — diagnostics / stats
packRow("#.#.#"),
packRow("#.#.#"),
packRow("#####"));
MINI_ICON(ICON_GEAR, 7, // ⚙ cog with hub hole — system
packRow("..#.#.."),
packRow(".#####."),
packRow("#######"),
packRow(".##.##."),
packRow("#######"),
packRow(".#####."),
packRow("..#.#.."));
MINI_ICON(ICON_KEY, 5, // padlock — remote admin (privileged/password-gated access)
packRow(".###."),
packRow("#...#"),
@@ -347,28 +343,16 @@ MINI_ICON(ICON_PG_BT, 5, // bluetooth (compact)
packRow(".###."),
packRow("#.##."),
packRow("..#.."));
MINI_ICON(ICON_PG_ADVERT, 5, // mast + radiating waves — advert
packRow("#...#"),
packRow(".#.#."),
packRow("..#.."),
packRow("..#.."),
packRow("..#.."));
MINI_ICON(ICON_PG_GPS, 5, // location pin — GPS
packRow(".###."),
packRow("#.#.#"),
packRow("#.#.#"),
packRow(".#.#."),
packRow("..#.."));
MINI_ICON(ICON_PG_SENSORS, 5, // thermometer/gauge — sensors
packRow("..#.."),
packRow(".#.#."),
packRow(".#.#."),
packRow(".###."),
packRow(".###."));
MINI_ICON(ICON_PG_SETTINGS, 5, // small cog — settings
MINI_ICON(ICON_PG_ADVERT, 7, // advert page: the same waves
packRow(".#...#."),
packRow("#.###.#"),
packRow("#.###.#"),
packRow("#.###.#"),
packRow(".#...#."));
MINI_ICON(ICON_PG_SETTINGS, 5, // cog with a hub hole: settings
packRow(".#.#."),
packRow("#####"),
packRow(".#.#."),
packRow("##.##"),
packRow("#####"),
packRow(".#.#."));
MINI_ICON(ICON_PG_MAP, 5, // folded map — map page
@@ -377,18 +361,17 @@ MINI_ICON(ICON_PG_MAP, 5, // folded map — map page
packRow("#.#.#"),
packRow("#.#.#"),
packRow("#####"));
MINI_ICON(ICON_PG_TOOLS, 5, // wrench (open jaw + handle) — tools
packRow(".#.#."),
packRow(".###."),
packRow("..#.."),
packRow("..#.."),
packRow("..#.."));
MINI_ICON(ICON_PG_MSG, 5, // speech bubble — quick messages
packRow("#####"),
packRow("#...#"),
packRow("#...#"),
packRow("#####"),
packRow(".#..."));
MINI_ICON(ICON_PG_TOOLS, 6, // wrench on the diagonal, open jaw top right: tools
packRow("...#.#"),
packRow("...###"),
packRow("..##.."),
packRow(".##..."),
packRow("##...."));
MINI_ICON(ICON_PG_MSG, 7, // speech bubble with three dots: messages
packRow("#######"),
packRow("#.#.#.#"),
packRow("#######"),
packRow("#......"));
MINI_ICON(ICON_PG_POWER, 5, // power symbol — shutdown
packRow("..#.."),
packRow("#.#.#"),
@@ -656,292 +639,6 @@ inline void drawRowSelection(DisplayDriver& d, int y, bool sel, int reserve) {
d.drawSelectionRow(0, y - 1, d.width() - reserve, d.lineStep() - 1, sel);
}
// ── Big ASCII-art icons ─────────────────────────────
// Same authoring idea as the mini-icons but for full page glyphs up to 32 px
// wide: one uint32_t per row, readable in source. Drawn at 1× (page icons
// aren't font-scaled).
//
// To add one:
// BIG_ICON(MY_ICON, 16,
// packRow32("......####......"),
// packRow32(".....######....."),
// ...);
// bigIconDraw(display, x, y, MY_ICON);
constexpr uint32_t packBit32(const char* s, int x) {
return (s[x] && s[x] != ' ' && s[x] != '.') ? (1u << x) : 0u;
}
constexpr uint32_t packRow32(const char* s, int x = 0) {
return (!s[x] || x >= 32) ? 0u : (packBit32(s, x) | packRow32(s, x + 1));
}
struct BigIcon { uint8_t w, h; const uint32_t* rows; };
#define BIG_ICON(name, width, ...) \
static constexpr uint32_t name##_rows[] = { __VA_ARGS__ }; \
static constexpr BigIcon name = { \
(uint8_t)(width), \
(uint8_t)(sizeof(name##_rows) / sizeof(uint32_t)), name##_rows }
// Draw a packed big icon at 1× with the current ink colour, top-left at (x, y).
// `dim` draws only every other pixel (50% checker): the icon's "off" state.
inline void bigIconDraw(DisplayDriver& d, int x, int y, const BigIcon& ic, bool dim = false) {
for (int r = 0; r < ic.h; r++) {
if (dim) {
for (int c = (r + x + y) & 1; c < ic.w; c += 2)
if (ic.rows[r] & (1u << c)) d.fillRect(x + c, y + r, 1, 1);
continue;
}
for (int c = 0; c < ic.w; ) { // runs of set bits as one rect
if (!(ic.rows[r] & (1u << c))) { c++; continue; }
int e = c;
while (e + 1 < ic.w && (ic.rows[r] & (1u << (e + 1)))) e++;
d.fillRect(x + c, y + r, e - c + 1, 1);
c = e + 1;
}
}
}
// Home page icons, 32x32, one family: 3 px strokes, rounded ends. Drawn
// through drawHoverIcon() below. Rasterised from vector shapes, so keep any
// redraw to the same stroke width rather than touching single pixels.
// Bluetooth rune
BIG_ICON(BIG_BLUETOOTH, 32,
packRow32("................................"),
packRow32("................................"),
packRow32("..............##................"),
packRow32(".............####..............."),
packRow32(".............#####.............."),
packRow32(".............######............."),
packRow32(".............#######............"),
packRow32(".............#########.........."),
packRow32(".............####.#####........."),
packRow32("........##...####..####........."),
packRow32(".......####..####..####........."),
packRow32("........####.####.#####........."),
packRow32(".........############..........."),
packRow32("..........##########............"),
packRow32("...........########............."),
packRow32("............######.............."),
packRow32("............######.............."),
packRow32("...........########............."),
packRow32("..........##########............"),
packRow32(".........############..........."),
packRow32("........####.####.#####........."),
packRow32(".......####..####..####........."),
packRow32("........##...####..####........."),
packRow32(".............####.#####........."),
packRow32(".............#########.........."),
packRow32(".............#######............"),
packRow32(".............######............."),
packRow32(".............#####.............."),
packRow32(".............####..............."),
packRow32("..............##................"),
packRow32("................................"),
packRow32("................................"));
// advert: a dot between two pairs of arcs
BIG_ICON(BIG_ADVERT, 32,
packRow32("................................"),
packRow32("................................"),
packRow32("................................"),
packRow32("................................"),
packRow32("................................"),
packRow32("................................"),
packRow32("....#......................#...."),
packRow32("...####..................####..."),
packRow32("...###....................###..."),
packRow32("..###......................###.."),
packRow32("..###...##............##...###.."),
packRow32(".###...####..........####...###."),
packRow32(".###...###.....##.....###...###."),
packRow32(".###..###....######....###..###."),
packRow32(".##...###....######....###...##."),
packRow32(".##...###...########...###...##."),
packRow32(".##...###...########...###...##."),
packRow32(".##...###....######....###...##."),
packRow32(".###..###....######....###..###."),
packRow32(".###...###.....##.....###...###."),
packRow32(".###...####..........####...###."),
packRow32("..###...##............##...###.."),
packRow32("..###......................###.."),
packRow32("...###....................###..."),
packRow32("...####..................####..."),
packRow32("....#......................#...."),
packRow32("................................"),
packRow32("................................"),
packRow32("................................"),
packRow32("................................"),
packRow32("................................"),
packRow32("................................"));
// power (Hibernate)
BIG_ICON(BIG_POWER, 32,
packRow32("................................"),
packRow32("................................"),
packRow32("...............##..............."),
packRow32("..............####.............."),
packRow32("..............####.............."),
packRow32("..............####.............."),
packRow32("..............####.............."),
packRow32("..............####.............."),
packRow32(".......##.....####.....##......."),
packRow32("......####....####....####......"),
packRow32(".....####.....####.....####....."),
packRow32("....####......####......####...."),
packRow32("....###.......####.......###...."),
packRow32("....###.......####.......###...."),
packRow32("...###........####........###..."),
packRow32("...###.........##.........###..."),
packRow32("...###....................###..."),
packRow32("...###....................###..."),
packRow32("...###....................###..."),
packRow32("...###....................###..."),
packRow32("...###....................###..."),
packRow32("....###..................###...."),
packRow32("....###..................###...."),
packRow32("....####................####...."),
packRow32(".....####..............####....."),
packRow32("......####............####......"),
packRow32(".......#####........#####......."),
packRow32("........################........"),
packRow32(".........##############........."),
packRow32("...........##########..........."),
packRow32("................................"),
packRow32("................................"));
// gear
BIG_ICON(BIG_SETTINGS, 32,
packRow32("................................"),
packRow32(".............######............."),
packRow32(".............######............."),
packRow32(".............######............."),
packRow32(".......##....######....##......."),
packRow32("......####...######...####......"),
packRow32(".....######################....."),
packRow32("....########################...."),
packRow32("....########################...."),
packRow32(".....######################....."),
packRow32("......####################......"),
packRow32("......#######......#######......"),
packRow32("......######........######......"),
packRow32(".##########..........##########."),
packRow32(".##########..........##########."),
packRow32(".##########..........##########."),
packRow32(".##########..........##########."),
packRow32(".##########..........##########."),
packRow32(".##########..........##########."),
packRow32("......######........######......"),
packRow32("......#######......#######......"),
packRow32("......####################......"),
packRow32(".....######################....."),
packRow32("....########################...."),
packRow32("....########################...."),
packRow32(".....######################....."),
packRow32("......####...######...####......"),
packRow32(".......##....######....##......."),
packRow32(".............######............."),
packRow32(".............######............."),
packRow32(".............######............."),
packRow32("................................"));
// wrench
BIG_ICON(BIG_TOOLS, 32,
packRow32("................................"),
packRow32("................................"),
packRow32(".....................##........."),
packRow32("..................#######......."),
packRow32(".................#######........"),
packRow32("................#######........."),
packRow32("...............#######.........."),
packRow32("...............######.......#..."),
packRow32("...............#####.......##..."),
packRow32("..............#######.....####.."),
packRow32("..............########...#####.."),
packRow32("...............########.#####..."),
packRow32("...............##############..."),
packRow32("...............##############..."),
packRow32("..............##############...."),
packRow32(".............##############....."),
packRow32("............##############......"),
packRow32("...........#######...##........."),
packRow32("..........#######..............."),
packRow32(".........#######................"),
packRow32("........#######................."),
packRow32(".......#######.................."),
packRow32("......#######..................."),
packRow32(".....#######...................."),
packRow32("....#######....................."),
packRow32("....######......................"),
packRow32("....#####......................."),
packRow32(".....###........................"),
packRow32("................................"),
packRow32("................................"),
packRow32("................................"),
packRow32("................................"));
// chat bubble with the loading-dot trio in it
BIG_ICON(BIG_MESSAGES, 32,
packRow32("................................"),
packRow32("................................"),
packRow32("................................"),
packRow32("................................"),
packRow32("................................"),
packRow32("......####################......"),
packRow32("....########################...."),
packRow32("....########################...."),
packRow32("...####..................####..."),
packRow32("...###....................###..."),
packRow32("...###....................###..."),
packRow32("...###....................###..."),
packRow32("...###..####..####..####..###..."),
packRow32("...###..####..####..####..###..."),
packRow32("...###..####..####..####..###..."),
packRow32("...###....................###..."),
packRow32("...###....................###..."),
packRow32("...###....................###..."),
packRow32("...####..................####..."),
packRow32("....########################...."),
packRow32("....########################...."),
packRow32("......####################......"),
packRow32("........#####..................."),
packRow32("........####...................."),
packRow32("........###....................."),
packRow32("........##......................"),
packRow32("........#......................."),
packRow32("................................"),
packRow32("................................"),
packRow32("................................"),
packRow32("................................"),
packRow32("................................"));
// A Home page icon hovering over its spot: it bobs up to 2 px on a slow
// cosine while a dithered blob below it, its shadow, shrinks as it rises and
// spreads as it sinks. Centred on cx in the 32 px box at y: the glyphs leave
// a row or two blank at the top, so the icon rests 1 px high and the shadow
// fits in the rows the old static icon left above its title. Put the title at
// y + h + HOVER_GAP. E-ink gets the resting frame. Returns ms until the next
// frame is due.
constexpr int HOVER_GAP = 3;
// Where the bob is now: 0 = resting, 1 = top. Anything riding on the icon (a
// badge) lifts by hoverLift() so it moves with it.
inline float hoverPhase(DisplayDriver& d) {
if (d.isEink()) return 0;
const unsigned long period = 2400;
return (1.0f - cosf(6.2831853f * (float)(millis() % period) / period)) * 0.5f;
}
inline int hoverLift(DisplayDriver& d) { return 1 + (int)(2 * hoverPhase(d) + 0.5f); }
inline int drawHoverIcon(DisplayDriver& d, int cx, int y, const BigIcon& ic, bool dim = false) {
const bool anim = !d.isEink();
const float up = hoverPhase(d);
d.setColor(DisplayDriver::LIGHT);
bigIconDraw(d, cx - ic.w / 2, y - 1 - (int)(2 * up + 0.5f), ic, dim);
// Shadow: three rows, a checker oval with a solid core that fades as it rises.
const int w = (int)(28 - 10 * up + 0.5f), core = (int)(w * 0.5f * (1 - up) + 0.5f);
const int sy = y + ic.h - 1;
for (int r = 0; r < 3; r++) {
const int rw = r == 1 ? w : w - 8, x0 = cx - rw / 2;
for (int x = x0; x < x0 + rw; x++)
if (((x + sy + r) & 1) == 0 || (r == 1 && 2 * (x - cx) + 1 < core && 2 * (cx - x) - 1 < core))
d.fillRect(x, sy + r, 1, 1);
}
return anim ? 60 : 1000;
}
// Favourite marker for a list row, on every screen that lists something
// starrable. Reuses the Favourites page's own icon so the two read as one idea.
// Width includes the gap that separates it from the text to its left.