diff --git a/examples/companion_radio/Features.h b/examples/companion_radio/Features.h index fdffdb38..4f28996c 100644 --- a/examples/companion_radio/Features.h +++ b/examples/companion_radio/Features.h @@ -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; diff --git a/examples/companion_radio/ui-core/MessageHistory.h b/examples/companion_radio/ui-core/MessageHistory.h index d8197be2..5e57e355 100644 --- a/examples/companion_radio/ui-core/MessageHistory.h +++ b/examples/companion_radio/ui-core/MessageHistory.h @@ -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; diff --git a/examples/companion_radio/ui-new/AutoAdvertScreen.h b/examples/companion_radio/ui-new/AutoAdvertScreen.h index 0aefe461..2405b109 100644 --- a/examples/companion_radio/ui-new/AutoAdvertScreen.h +++ b/examples/companion_radio/ui-new/AutoAdvertScreen.h @@ -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) {} diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index b288da48..c99264e4 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -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); }); diff --git a/examples/companion_radio/ui-new/StatusScreen.h b/examples/companion_radio/ui-new/StatusScreen.h index 9be97040..caf7f92b 100644 --- a/examples/companion_radio/ui-new/StatusScreen.h +++ b/examples/companion_radio/ui-new/StatusScreen.h @@ -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; diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index edfd2655..ac4d3493 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -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 " 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[] = { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index bbb59315..c5f45347 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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; } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 6043e224..4e01d66b 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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 diff --git a/examples/companion_radio/ui-new/icons.h b/examples/companion_radio/ui-new/icons.h index 7b7d9fab..763b7adb 100644 --- a/examples/companion_radio/ui-new/icons.h +++ b/examples/companion_radio/ui-new/icons.h @@ -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.