diff --git a/examples/companion_radio/ui-new/ClockToolsScreen.h b/examples/companion_radio/ui-new/ClockToolsScreen.h index fba5945a..17fd4916 100644 --- a/examples/companion_radio/ui-new/ClockToolsScreen.h +++ b/examples/companion_radio/ui-new/ClockToolsScreen.h @@ -185,15 +185,37 @@ class ClockToolsScreen : public UIScreen { return (mq_delay > 0 && mq_delay < 60000) ? mq_delay : 60000; } + // On a screen 40 characters wide (the 4.2"): `t` in the big clock's + // lettering, as large as fits between the header and the hint line, with + // character `cur` underlined (-1: none). False elsewhere: draw it in text. + bool bigDigits(DisplayDriver& d, const char* t, int cur) { + if (d.width() < 40 * d.getCharWidth()) return false; + const int lh = d.getLineHeight(), top = d.listStart(), bottom = d.height() - 2 * lh; + int sc = 6; // the Home clock's size at most + while (sc > 1 && (info::letteringW(t, sc, sc + 1) > d.width() - 2 * lh + || lettering::LOGO_H * sc > bottom - top - lh)) sc--; + const int gap = sc + 1, h = lettering::LOGO_H * sc; + const int x0 = (d.width() - info::letteringW(t, sc, gap)) / 2, y = top + (bottom - top - h) / 2; + info::lettering(d, x0, y, t, sc, gap); + if (cur >= 0) { + int x = x0; + for (int i = 0; i < cur && t[i]; i++) x += lettering::charW(t[i]) * sc + gap; + d.fillRect(x, y + h + sc, lettering::charW(t[cur]) * sc, sc); + } + return true; + } + int renderTimer(DisplayDriver& d) { drawScreenHeader(d, "Timer"); const int cx = d.width() / 2; if (_task->isTimerRunning()) { char buf[16]; fmtHMS(buf, sizeof(buf), _task->timerRemainingMs()); - d.setTextSize(2); - d.drawTextCentered(cx, d.listStart() + 4, buf); - d.setTextSize(1); + if (!bigDigits(d, buf, -1)) { + d.setTextSize(2); + d.drawTextCentered(cx, d.listStart() + 4, buf); + d.setTextSize(1); + } d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, "Ent=stop"); return liveTickMs(d, 1000); } @@ -201,6 +223,10 @@ class ClockToolsScreen : public UIScreen { // LEFT/RIGHT move the cursor one digit, UP/DOWN change it, Enter starts. char buf[12]; snprintf(buf, sizeof(buf), "%02u:%02u:%02u", _timer_h, _timer_m, _timer_s); + if (bigDigits(d, buf, _timer_cur + (_timer_cur / 2))) { + d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, "Up/Dn=set Ent=start"); + return 60000; + } d.setTextSize(2); const int ty = d.listStart() + 2; d.drawTextCentered(cx, ty, buf); @@ -220,9 +246,11 @@ class ClockToolsScreen : public UIScreen { char buf[16]; bool tenths = !d.isEink() && _sw_running; // tenths only on a fast panel fmtStopwatch(buf, sizeof(buf), stopwatchMs(), tenths); - d.setTextSize(2); - d.drawTextCentered(cx, d.listStart() + 4, buf); - d.setTextSize(1); + if (!bigDigits(d, buf, -1)) { + d.setTextSize(2); + d.drawTextCentered(cx, d.listStart() + 4, buf); + d.setTextSize(1); + } const char* hint = _sw_running ? "Ent=stop" : "Ent=start Dn=reset"; d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, hint); return _sw_running ? liveTickMs(d, 100) : 60000; diff --git a/examples/companion_radio/ui-new/InfoKit.h b/examples/companion_radio/ui-new/InfoKit.h index caa4c445..61b09da1 100644 --- a/examples/companion_radio/ui-new/InfoKit.h +++ b/examples/companion_radio/ui-new/InfoKit.h @@ -6,10 +6,12 @@ // fixed 128x64, so a larger-font e-ink layout gets them scaled for free. #include +#include #include #include #include "icons.h" #include "../ui-core/StatusHistory.h" +#include "../ui-core/Lettering.h" namespace info { @@ -62,8 +64,9 @@ inline void section(DisplayDriver& d, int y, const char* label, int reserve = 0) } // A battery glyph the size of a mini icon (7 x 5 at 1x, 9 x 7 in the large -// set), its corners softly rounded, filled to frac. -static constexpr int BATT_BODY_W = MINI_ICONS_LARGE ? 8 : 6, BATT_BODY_H = MINI_ICONS_LARGE ? 7 : 5; +// set, 12 x 9 in the 9 px one), its corners softly rounded, filled to frac. +static constexpr int BATT_BODY_W = MINI_ICONS_LARGE == 2 ? 11 : MINI_ICONS_LARGE ? 8 : 6, + BATT_BODY_H = MINI_ICONS_LARGE == 2 ? 9 : MINI_ICONS_LARGE ? 7 : 5; inline int batteryW(DisplayDriver& d) { return (BATT_BODY_W + 1) * miniIconScale(d); } inline int batteryH(DisplayDriver& d) { return BATT_BODY_H * miniIconScale(d); } inline void battery(DisplayDriver& d, int x, int y, float frac) { @@ -297,26 +300,44 @@ inline void fadeColumn(DisplayDriver& d, int x, int y0, int y1) { // or, with `bars`, one bar each from the bottom, for counts -- the newest at // the right edge, then a caption line under the frame: `left` at its left // (how far back it goes), `right` at its right. chartH() is the whole -// block's height. +// block's height. `axis`, on a chart 30 characters wide or more (the 4.2" +// e-ink), prints the top, middle and bottom values in a column to the left +// of the frame: it writes one sample value in the unit the reader knows. inline int chartH(DisplayDriver& d, int frame_h) { return frame_h + d.getLineHeight() + 3; } +typedef void (*ChartAxisFmt)(char* buf, int n, int v); template inline void chart(DisplayDriver& d, int x, int y, int w, int h, const History& hs, int min_span, - bool bars, const char* left, const char* right) { + bool bars, const char* left, const char* right, ChartAxisFmt axis = nullptr) { + const bool empty = hs.n < (bars ? 1 : 2); + int lo = 0, hi = 0; + if (!empty) { + lo = bars ? 0 : hs.at(0); hi = lo; + for (int i = 0; i < hs.n; i++) { int v = hs.at(i); if (v < lo) lo = v; if (v > hi) hi = v; } + if (hi - lo < min_span) { if (bars) hi = lo + min_span; else { int mid = (hi + lo) / 2; lo = mid - min_span / 2; hi = lo + min_span; } } + } + const int lh = d.getLineHeight(); + if (axis && !empty && w >= 30 * d.getCharWidth() && h >= 2 * lh) { + char t[3][12]; + const int v[3] = { hi, (hi + lo) / 2, lo }; + int gw = 0; + for (int k = 0; k < 3; k++) { axis(t[k], sizeof(t[k]), v[k]); int tw = d.getTextWidth(t[k]); if (tw > gw) gw = tw; } + d.drawTextRightAlign(x + gw, y + 1, t[0]); + if (h >= 3 * lh) d.drawTextRightAlign(x + gw, y + (h - lh) / 2, t[1]); + d.drawTextRightAlign(x + gw, y + h - lh - 1, t[2]); + x += gw + 3; w -= gw + 3; + } d.drawSoftRect(x, y, w, h); const int s = miniIconScale(d); for (int i = x + 3; i < x + w - 3; i += 3 * s) d.fillRect(i, y + h / 2, s, s); const int ix = x + 2, iy = y + 2, iw = w - 4, ih = h - 4; - if (hs.n < (bars ? 1 : 2)) { // nothing to draw yet: say so over the line - const int tw = d.getTextWidth("no data"), tx = x + (w - tw) / 2, ty = y + (h - d.getLineHeight()) / 2; + if (empty) { // nothing to draw yet: say so over the line + const int tw = d.getTextWidth("no data"), tx = x + (w - tw) / 2, ty = y + (h - lh) / 2; d.setColor(DisplayDriver::DARK); - d.fillRect(tx - 2, ty, tw + 4, d.getLineHeight()); + d.fillRect(tx - 2, ty, tw + 4, lh); d.setColor(DisplayDriver::LIGHT); d.setCursor(tx, ty); d.print("no data"); } else { - int lo = bars ? 0 : hs.at(0), hi = lo; - for (int i = 0; i < hs.n; i++) { int v = hs.at(i); if (v < lo) lo = v; if (v > hi) hi = v; } - if (hi - lo < min_span) { if (bars) hi = lo + min_span; else { int mid = (hi + lo) / 2; lo = mid - min_span / 2; hi = lo + min_span; } } const int bw = iw / N > 1 ? iw / N : 1; int px = -1, py = 0; for (int i = 0; i < hs.n; i++) { @@ -339,6 +360,33 @@ inline void chart(DisplayDriver& d, int x, int y, int w, int h, const History if (right && *right) d.drawTextRightAlign(x + w, ty, right); } +// Axis formats for chart(): the unit each History is kept in, as read. +inline void axisPlain(char* b, int n, int v) { snprintf(b, n, "%d", v); } +inline void axisVolts(char* b, int n, int v) { snprintf(b, n, "%.2f", v / 1000.0f); } // mV +inline void axisQuarterDb(char* b, int n, int v) { snprintf(b, n, "%.0f", v / 4.0f); } // dB x4 + +// Text in the splash's slanted lettering (ui-core/Lettering.h: digits, ':', +// '.', '-' and a few letters) at `sc` px a pixel, `gap` px between +// characters, its top-left at x / y. letteringW() measures it the same way. +inline void lettering(DisplayDriver& d, int x, int y, const char* t, int sc, int gap) { + for (const char* p = t; *p; p++) { + const int cw = ::lettering::charW(*p); + if (cw < 0) continue; + for (int r = 0; r < ::lettering::LOGO_H; r++) + for (int col = 0; col < cw; col++) { + if (!::lettering::inked(*p, col, r)) continue; + int e = col; + while (e + 1 < cw && ::lettering::inked(*p, e + 1, r)) e++; + d.fillRect(x + col * sc, y + r * sc, (e - col + 1) * sc, sc); + col = e; + } + x += cw * sc + gap; + } +} +inline int letteringW(const char* t, int sc, int gap) { + return ::lettering::textW(t, 0) * sc + gap * ((int)strlen(t) - 1); +} + // Blocks stacked down from `top`, with the first `skip` left out: the way a // tab scrolls by whole blocks. Call place(h) before drawing each block; it // says whether the block is on screen and where (at). The totals then give diff --git a/examples/companion_radio/ui-new/NavView.h b/examples/companion_radio/ui-new/NavView.h index 59888452..61c01a53 100644 --- a/examples/companion_radio/ui-new/NavView.h +++ b/examples/companion_radio/ui-new/NavView.h @@ -10,6 +10,7 @@ #include #include "../GeoUtils.h" #include "../ui-core/EtaTracker.h" // navview::EtaTracker (shared with ui-lvgl) +#include "InfoKit.h" // info::rose() namespace navview { @@ -42,12 +43,33 @@ inline void draw(DisplayDriver& d, const int step = d.lineStep(); int y = d.listStart(); + // A screen 40 characters wide (the 4.2"): the lines down the left, and a + // compass rose (north up) at the right with an arrow to the target and a + // mark on the rim where you're heading. + const bool wide = d.width() >= 40 * d.getCharWidth(); // Distance — emphasised at size 2. d.setTextSize(2); - d.drawTextCentered(cx, y, dist); + if (wide) { d.setCursor(2, y); d.print(dist); } + else d.drawTextCentered(cx, y, dist); y += d.getLineHeight() + 3; d.setTextSize(1); + if (wide) { + const int lh = d.getLineHeight(), top = d.listStart(); + const int r = (d.height() - top) / 2 - lh; + const int rx = d.width() - r - lh, ry = top + (d.height() - top) / 2 + lh / 4; + info::rose(d, rx, ry, r, to_deg); + if (cog_valid) { // a small filled triangle outside the rim, pointing in + const float a = cog_deg * (float)M_PI / 180.0f, sa = sinf(a), ca = cosf(a); + const int s = miniIconScale(d) * 2; + for (int k = 0; k <= 3 * s; k++) { + const float rr = r + 2 + k, half = k / 2.0f; + const int px = rx + (int)lroundf(rr * sa), py = ry - (int)lroundf(rr * ca); + info::line(d, px - (int)lroundf(half * ca), py - (int)lroundf(half * sa), + px + (int)lroundf(half * ca), py + (int)lroundf(half * sa)); + } + } + } char line[20]; snprintf(line, sizeof(line), "To: %d %s", to_deg, geo::bearingCardinal(to_deg)); diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index ae2539b4..daf8616c 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -434,23 +434,29 @@ class NearbyScreen : public UIScreen, protected NearbyModel { // ── detail rendering ────────────────────────────────────────────────────────── void renderStoredDetail(DisplayDriver& display) { const Entry& e = _entries[_sel]; - const int hdr = display.listStart(); // content top (gap below the header separator) display.drawInvertedHeader(e.name, true, ctxMenuOpen()); + drawStoredFields(display, e, 0, display.listStart(), display.width(), display.height()); + } + // A stored node's position, distance, type, age and path, in the box from + // x0 / top, w wide down to bottom: the detail view's body, and on a wide + // screen the pane beside the list. + void drawStoredFields(DisplayDriver& display, const Entry& e, int x0, int top, int w, int bottom) { + const int tx = x0 + 2; int step = display.lineStep(); - if (step * 5 > display.height() - hdr) step = (display.height() - hdr) / 5; + if (step * 5 > bottom - top) step = (bottom - top) / 5; char buf[32]; // Without a line to spare for the path (the OLED), the position takes one // row, unlabelled: then there are five rows either way. - const bool one_pos = hdr + step * 5 + display.getLineHeight() > display.height(); + const bool one_pos = top + step * 5 + display.getLineHeight() > bottom; if (one_pos) { snprintf(buf, sizeof(buf), "%.5f,%.5f", e.lat_e6 / 1e6, e.lon_e6 / 1e6); - display.drawTextEllipsized(2, hdr, display.width() - 4, buf); + display.drawTextEllipsized(tx, top, w - 4, buf); } else { snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6); - display.setCursor(2, hdr); display.print(buf); + display.setCursor(tx, top); display.print(buf); snprintf(buf, sizeof(buf), "Lon: %.5f", e.lon_e6 / 1e6); - display.setCursor(2, hdr + step); display.print(buf); + display.setCursor(tx, top + step); display.print(buf); } const int r0 = one_pos ? -1 : 0; // the rows under the position move up one @@ -462,34 +468,34 @@ class NearbyScreen : public UIScreen, protected NearbyModel { } else { snprintf(buf, sizeof(buf), "Dist: no GPS"); } - display.setCursor(2, hdr + step * (2 + r0)); display.print(buf); + display.setCursor(tx, top + step * (2 + r0)); display.print(buf); snprintf(buf, sizeof(buf), "Type: %s", typeName(e.type)); - display.setCursor(2, hdr + step * (3 + r0)); display.print(buf); + display.setCursor(tx, top + step * (3 + r0)); display.print(buf); char age[16]; fmtAge(age, sizeof(age), e.lastmod); // For a live [LOC] row, label the timestamp as a position share and note // whether the sender's identity is verified (DM) or name-only (channel). if (e.is_live) snprintf(buf, sizeof(buf), "Sharing pos: %s %s", age, e.live_verified ? "(DM)" : "(chan)"); else snprintf(buf, sizeof(buf), "Seen: %s", age); - display.drawTextEllipsized(2, hdr + step * (4 + r0), display.width() - 4, buf); + display.drawTextEllipsized(tx, top + step * (4 + r0), w - 4, buf); - const int W = display.width(), H = display.height(), lh = display.getLineHeight(); + const int lh = display.getLineHeight(); int rows = 5 + r0; // How a message gets there: a chain from you to it, a ring per repeater // on the way. ContactInfo ci; - if (hdr + step * rows + lh <= H && e.contact_idx >= 0 && the_mesh.getContactByIdx(e.contact_idx, ci)) { - const int y = hdr + step * rows; - display.setCursor(2, y); + if (top + step * rows + lh <= bottom && e.contact_idx >= 0 && the_mesh.getContactByIdx(e.contact_idx, ci)) { + const int y = top + step * rows; + display.setCursor(tx, y); display.print("Path:"); - int x = 2 + display.getTextWidth("Path: "); + int x = tx + display.getTextWidth("Path: "); if (ci.out_path_len == 0xFF) { display.print(" flood"); } else { const int hops = ci.out_path_len & 63; const int r = lh / 4 > 2 ? lh / 4 : 2, cy = y + lh / 2 - 1, gap = r + 3; // up to 5 rings, as many as leave room for the count after them - const int fit = (W - 2 - x - display.getTextWidth("5 hops")) / (2 * r + 1 + gap) - 2; + const int fit = (x0 + w - 2 - x - display.getTextWidth("5 hops")) / (2 * r + 1 + gap) - 2; const int cap = fit < 5 ? (fit < 1 ? 1 : fit) : 5, shown = hops > cap ? cap : hops; for (int k = 0; k <= shown + 1; k++) { // you and it filled, the hops as rings if (k) for (int dx = x - gap + 1; dx < x; dx += 2) display.fillRect(dx, cy, 1, 1); @@ -508,11 +514,11 @@ class NearbyScreen : public UIScreen, protected NearbyModel { // Where it is, on a compass rose (north up), where the screen has the room // (e-ink): beside the rows on a wide one, under them on a tall one. if (!Features::IS_EINK || e.dist_km < 0.0f) return; - const int below = hdr + step * rows + lh; + const int below = top + step * rows + lh; int r, cx, cy; - if (H - below > W / 2) { r = (W < H - below ? W : H - below) / 2 - lh; cx = W / 2; cy = below + lh / 2 + r; } - else { r = (H - hdr) / 2 - lh / 2 - 1; cx = W - r - 3; cy = hdr + lh / 2 + r; } - const int text_r = 2 + display.getTextWidth("Lon: -000.00000"); + if (bottom - below > w / 2) { r = (w < bottom - below ? w : bottom - below) / 2 - lh; cx = x0 + w / 2; cy = below + lh / 2 + r; } + else { r = (bottom - top) / 2 - lh / 2 - 1; cx = x0 + w - r - 3; cy = top + lh / 2 + r; } + const int text_r = tx + display.getTextWidth("Lon: -000.00000"); if (r < 2 * lh || (cy - r < below - lh && cx - r < text_r)) return; info::rose(display, cx, cy, r, geo::bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6)); } @@ -661,10 +667,13 @@ public: _list_refresh_ms = millis(); } - int item_h = display.lineStep(); + // A screen 40 characters wide (the 4.2" e-ink) keeps the list to the left + // and shows the highlighted node's detail beside it. + const bool split = _source == SRC_STORED && _count > 0 && display.width() >= 40 * display.getCharWidth(); + const int lw = split ? display.width() * 11 / 20 : display.width(); // the list's right edge // An arrow towards the node before its distance: one more cell. const bool arrows = _source == SRC_STORED && _sort != SORT_TIME; - int dist_col = display.width() - display.getCharWidth() * (arrows ? 9 : 7); + int dist_col = lw - display.getCharWidth() * (arrows ? 9 : 7); display.setColor(DisplayDriver::LIGHT); const char* flt = (_filter != F_ALL) ? filterLabel(_filter) : nullptr; @@ -708,7 +717,7 @@ public: drawList(display, _count, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) { const Entry& e = _entries[idx]; - drawRowSelection(display, y, sel, reserve); + drawRowSelection(display, y, sel, reserve, lw); char filt[32]; int tx = 2; @@ -737,7 +746,7 @@ public: display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); char right[10]; if (_source == SRC_SCAN) { // live scan: how well we hear it, as bars - drawSignalBars(display, display.width() - reserve - 2, y, e.snr_x4); + drawSignalBars(display, lw - reserve - 2, y, e.snr_x4); right[0] = '\0'; } else if (_sort == SORT_TIME) { geo::fmtAgeShort(right, sizeof(right), rtc_clock.getCurrentTime(), e.lastmod); @@ -746,14 +755,21 @@ public: if (e.dist_km >= 0.0f) geo::fmtDist(right, sizeof(right), e.dist_km, useImperial()); else strcpy(right, "-"); // no fix of ours or theirs } - if (right[0]) display.drawTextRightAlign(display.width() - reserve - 2, y, right); + if (right[0]) display.drawTextRightAlign(lw - reserve - 2, y, right); if (arrows && e.dist_km >= 0.0f) { const int deg = geo::bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6); const MiniIcon& ic = *ICON_ARROWS[((deg + 22) % 360) / 45]; - miniIconDraw(display, display.width() - reserve - 2 - display.getTextWidth(right) - 3 + miniIconDraw(display, lw - reserve - 2 - display.getTextWidth(right) - 3 - ic.w * miniIconScale(display), y, ic); } - }); + }, lw); + if (split && _sel < _count) { + const int top = display.listStart(); + display.setColor(DisplayDriver::LIGHT); + display.fillRect(lw + 2, top, display.sepH(), display.height() - top); + drawStoredFields(display, _entries[_sel], lw + 2 + display.sepH() + 2, top, + display.width() - lw - 4 - display.sepH(), display.height()); + } } if (renderActivePopup(display)) return 50; diff --git a/examples/companion_radio/ui-new/RingtoneEditorScreen.h b/examples/companion_radio/ui-new/RingtoneEditorScreen.h index 6a27c018..471204b9 100644 --- a/examples/companion_radio/ui-new/RingtoneEditorScreen.h +++ b/examples/companion_radio/ui-new/RingtoneEditorScreen.h @@ -82,16 +82,24 @@ public: const int lh = display.getLineHeight(); const int cw = display.getCharWidth(); - const int cell_w = cw * 2 + 6; // fits 2-char label with margin + const bool wide = display.width() >= 40 * cw; + const int cell_w = cw * 2 + (wide ? 12 : 6); // fits 2-char label with margin const int notes_y = display.listStart(); const int cell_h = lh + 6; _visible_notes = display.width() / cell_w; - char hdr[32]; - snprintf(hdr, sizeof(hdr), "M%d BPM:%u %d/%d", _slot + 1, soundctl::bpm(_bpm_idx), _len, MAX_NOTES); - display.setCursor(0, 0); - display.print(hdr); - display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); + // A screen 40 characters wide (the 4.2"): a titled header, and the + // melody drawn as a roll under the notes (see drawRoll()). + char hdr[40]; + if (wide) { + snprintf(hdr, sizeof(hdr), "Melody %d %u BPM %d/%d", _slot + 1, soundctl::bpm(_bpm_idx), _len, MAX_NOTES); + drawScreenHeader(display, hdr, -1, 0, true, _menu.active); + } else { + snprintf(hdr, sizeof(hdr), "M%d BPM:%u %d/%d", _slot + 1, soundctl::bpm(_bpm_idx), _len, MAX_NOTES); + display.setCursor(0, 0); + display.print(hdr); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); + } for (int i = 0; i < _visible_notes; i++) { int ni = _scroll + i; @@ -142,14 +150,42 @@ public: display.print("U/D to add note"); } + if (wide) drawRoll(display, info_y + lh + 6, bottom_y - 6, cell_w); display.setCursor(0, bottom_y); - display.print("ENT:oct MENU:opts"); + display.print(wide ? "Up/Down: note Enter: octave Hold: options" : "ENT:oct MENU:opts"); if (_menu.active) _menu.render(display); return 200; } + // The visible notes as a roll from y0 to y1: under each note's cell a bar + // as high as its pitch (C4 at the bottom to B6 at the top, a dotted line + // at each C) and as wide as its length (a 1/4 fills the cell); a rest is + // a dash on the floor. The selected note's column is marked at its sides. + void drawRoll(DisplayDriver& d, int y0, int y1, int cell_w) { + const int steps = (soundctl::OCT_MAX - soundctl::OCT_MIN + 1) * 7; // C4..B6 + const int h = y1 - y0, W = _visible_notes * cell_w; + if (h < steps * 2) return; + const int bar_h = h / steps > 5 ? 5 : h / steps > 2 ? h / steps : 2; + d.setColor(DisplayDriver::LIGHT); + for (int o = 0; o <= soundctl::OCT_MAX - soundctl::OCT_MIN; o++) { + const int y = y1 - (o * 7) * h / steps; + for (int x = 0; x < W; x += 3) d.fillRect(x, y, 1, 1); + } + for (int i = 0; i < _visible_notes; i++) { + const int ni = _scroll + i, cx = i * cell_w; + if (ni == _cursor) + for (int y = y0; y < y1; y += 2) { d.fillRect(cx, y, 1, 1); d.fillRect(cx + cell_w - 2, y, 1, 1); } + if (ni >= _len) continue; + const uint8_t p = notePitch(_notes[ni]); + const int w = (cell_w - 4) >> noteDurIdx(_notes[ni]); + if (p == 0) { d.fillRect(cx + 2, y1 - 1, w > 2 ? w : 2, 1); continue; } + const int k = (noteOctave(_notes[ni]) - soundctl::OCT_MIN) * 7 + p - 1; + d.fillRect(cx + 2, y1 - (k + 1) * h / steps, w > 2 ? w : 2, bar_h); + } + } + void cycleMenuValue(int sel, int dir) { if (sel == MI_DURATION && _cursor < _len) { uint8_t p = notePitch(_notes[_cursor]); diff --git a/examples/companion_radio/ui-new/SkyView.h b/examples/companion_radio/ui-new/SkyView.h index c06c65e3..e4d5ae27 100644 --- a/examples/companion_radio/ui-new/SkyView.h +++ b/examples/companion_radio/ui-new/SkyView.h @@ -5,7 +5,8 @@ // north at the top): a filled dot is a satellite the fix uses, a // hollow one is tracked but unused, a single pixel is in view with // no signal. Beside it: fix, used / in view, HDOP, PDOP and the -// time to first fix (or how long it's been looking). +// time to first fix (or how long it's been looking), and on a +// 40-character screen a signal bar a satellite under them. // Signal -- a bar per satellite, its height the signal (C/N0, 50 dB-Hz = // full), filled when used; a dotted line marks 30 dB-Hz (good). // The top line counts used / in view per constellation. @@ -45,16 +46,19 @@ namespace skyview { return n; } + inline void signalRows(DisplayDriver& d, GpsSky& g, int x0, int y, int x1); + inline void renderSky(DisplayDriver& d, GpsSky& g, int top) { const int lh = d.getLineHeight(); const int cw = d.getCharWidth(); - const int s = miniIconScale(d); // A wide screen sets the numbers beside the plot, a tall one (portrait // e-ink) under it, the plot then as wide as the screen. const bool stacked = d.height() - top > d.width(); const int r = stacked ? (d.width() - 1) / 2 - 1 : (d.height() - 1 - top) / 2 - 1; const int cx = stacked ? d.width() / 2 : r + 1; const int cy = top + (stacked ? lh / 2 : 0) + r + 1; + // The marks grow with the plot, so a big one (the 4.2") doesn't lose them. + const int s = miniIconScale(d) > r / 40 ? miniIconScale(d) : r / 40; info::circle(d, cx, cy, r); // the horizon info::circle(d, cx, cy, r / 2, 2); // 45 degrees up, dotted @@ -80,6 +84,8 @@ namespace skyview { d.fillRect(x - s, y - s, 2 * s + 1, 2 * s + 1); d.setColor(DisplayDriver::LIGHT); d.drawRect(x - s, y - s, 2 * s + 1, 2 * s + 1); + } else if (s > 1) { // in view, no signal: a small point + d.fillRect(x - 1, y - 1, 3, 3); } else { dot(d, x, y); } @@ -113,6 +119,45 @@ namespace skyview { } else buf[0] = 0; d.setCursor(x0, y); d.print(buf); + // A screen 40 characters wide (the 4.2" e-ink) lists the strongest under + // the numbers as well, a bar each. + y += step + lh / 2; + if (!stacked && d.width() >= 40 * cw && d.height() - y >= 4 * lh) + signalRows(d, g, x0, y, d.width() - 2); + } + + // Satellites in order: used first, then the strongest. Returns the count. + inline int sorted(const GpsSky& g, int* idx) { + int n = g.count; + for (int i = 0; i < n; i++) idx[i] = i; + auto key = [&](int i) { return (g.used(g.sats[i]) ? 100 : 0) + g.sats[i].snr; }; + for (int i = 1; i < n; i++) { // insertion sort, <= 64 + int v = idx[i], j = i - 1; + while (j >= 0 && key(idx[j]) < key(v)) { idx[j + 1] = idx[j]; j--; } + idx[j + 1] = v; + } + return n; + } + + // A row a satellite from x0 / y to x1, as many as fit above the bottom: + // its system letter and number, then a bar as long as its signal (50 + // dB-Hz = full), filled when the fix uses it. + inline void signalRows(DisplayDriver& d, GpsSky& g, int x0, int y, int x1) { + const int lh = d.getLineHeight(), step = lh + 1; + int idx[GpsSky::MAX_SATS]; + const int n = sorted(g, idx); + const int bx = x0 + d.getTextWidth("G000") + 4, bw = x1 - bx, bh = lh - 6; + char buf[8]; + for (int k = 0; k < n && y + lh <= d.height(); k++, y += step) { + const GpsSky::Sat& sat = g.sats[idx[k]]; + snprintf(buf, sizeof(buf), "%c%d", GpsSky::sysLetter(sat.sys), sat.prn); + d.setCursor(x0, y); d.print(buf); + const int by = y + 3, snr = sat.snr > 50 ? 50 : sat.snr; + const int len = snr <= 0 ? 0 : (bw * snr / 50 > 2 ? bw * snr / 50 : 2); + if (!len) dot(d, bx, by + bh / 2); + else if (g.used(sat)) d.fillRect(bx, by, len, bh); + else d.drawRect(bx, by, len, bh); + } } inline void renderSignal(DisplayDriver& d, GpsSky& g, int top) { @@ -135,19 +180,13 @@ namespace skyview { // Used first, then the strongest. int idx[GpsSky::MAX_SATS]; - int n = g.count; - for (int i = 0; i < n; i++) idx[i] = i; - auto key = [&](int i) { return (g.used(g.sats[i]) ? 100 : 0) + g.sats[i].snr; }; - for (int i = 1; i < n; i++) { // insertion sort, <= 64 - int v = idx[i], j = i - 1; - while (j >= 0 && key(idx[j]) < key(v)) { idx[j + 1] = idx[j]; j--; } - idx[j + 1] = v; - } + int n = sorted(g, idx); int fit = (W + 1) / 3; // bars at least 2 px wide, 1 px apart if (n > fit) n = fit; if (!n) return; int bw = (W + 1) / n - 1; - if (bw > 8) bw = 8; + const int cap = W >= 40 * d.getCharWidth() ? W / 16 : 8; // wider on the 4.2" + if (bw > cap) bw = cap; const int y0 = top + lh + 3, y1 = d.height() - 1, H = y1 - y0; for (int x = 0; x < W; x += 3) dot(d, x, y1 - H * 30 / 50); // 30 dB-Hz diff --git a/examples/companion_radio/ui-new/StatusScreen.h b/examples/companion_radio/ui-new/StatusScreen.h index 4fde83e6..4880d60a 100644 --- a/examples/companion_radio/ui-new/StatusScreen.h +++ b/examples/companion_radio/ui-new/StatusScreen.h @@ -52,7 +52,9 @@ private: // E-ink draws them as charts (chartBlock()) over a longer span: a sample // every 5 minutes, 8 hours. Its Radio chart also has the SNR of the last // 32 packets. +public: static const int HIST_N = Features::IS_EINK ? 96 : 32; +private: static constexpr const char* HIST_SPAN = Features::IS_EINK ? "8 h ago" : "32 min"; uicore::StatusHistory _hist; @@ -60,16 +62,18 @@ private: // chart under it. template void chartBlock(DisplayDriver& d, info::Flow& f, int rsv, const char* title, const char* now, - const info::History& hs, int min_span, bool bars, const char* span) { + const info::History& hs, int min_span, bool bars, const char* span, + info::ChartAxisFmt axis) { const int lh = d.getLineHeight(), ch = 3 * lh; if (!f.place(d.lineStep() + info::chartH(d, ch) + 2)) return; info::valueRow(d, f.at, title, now, false, rsv, 1); - info::chart(d, 1, f.at + d.lineStep(), d.width() - rsv - 3, ch, hs, min_span, bars, span, "now"); + info::chart(d, 1, f.at + d.lineStep(), d.width() - rsv - 3, ch, hs, min_span, bars, span, "now", axis); } void addTab(uint8_t t, const char* label) { _tabs[_count] = t; _labels[_count] = label; _count++; } uint8_t tab() const { return _tabs[_cur]; } +public: static uint32_t totalRx() { uint32_t n = 0; for (uint8_t t = 0; t < 16; t++) n += the_mesh.getNumRecvByType(t); @@ -80,6 +84,7 @@ private: for (uint8_t t = 0; t < 16; t++) n += the_mesh.getNumSentByType(t); return n; } +private: int battMv() const { return _task->getBattMilliVolts(); } int battPct() const { @@ -120,7 +125,7 @@ private: const int nf = (int)radio_driver.getNoiseFloor(); if (nf) snprintf(a, sizeof(a), "%d dBm", nf); else strcpy(a, "-"); if (Features::IS_EINK) { - chartBlock(d, f, rsv, "Noise", a, _hist.noise, 6, false, HIST_SPAN); + chartBlock(d, f, rsv, "Noise", a, _hist.noise, 6, false, HIST_SPAN, info::axisPlain); } else if (f.place(d.lineStep())) { // label, the last half hour, the value now info::valueRow(d, f.at, "Noise", a, false, rsv, 1); const int x0 = 1 + d.getTextWidth("Noise") + 6; @@ -134,7 +139,7 @@ private: drawSignalBars(d, d.width() - rsv - 2 - d.getTextWidth(b) - 5, f.at, (int)(snr * 4)); } snprintf(b, sizeof(b), "%.1f dB", radio_driver.getLastSNR()); - if (Features::IS_EINK) chartBlock(d, f, rsv, "SNR", b, _hist.snr, 20, false, "32 pkts"); + if (Features::IS_EINK) chartBlock(d, f, rsv, "SNR", b, _hist.snr, 20, false, "32 pkts", info::axisQuarterDb); else row(d, f, rsv, "Last SNR", b); } @@ -210,7 +215,7 @@ private: row(d, f, rsv, "Battery", a); row(d, f, rsv, "Source", board.isExternalPowered() ? "USB" : "Battery"); if (Features::IS_EINK) { - chartBlock(d, f, rsv, "Trend", nullptr, _hist.batt, 20, false, HIST_SPAN); + chartBlock(d, f, rsv, "Trend", nullptr, _hist.batt, 20, false, HIST_SPAN, info::axisVolts); } else if (f.place(d.lineStep())) { d.setCursor(1, f.at); d.print("Trend"); @@ -255,7 +260,7 @@ private: if (Features::IS_EINK) { // packets per 5 minutes, as bars const int ch = 3 * lh; if (f.place(info::chartH(d, ch) + 2)) - info::chart(d, 1, f.at, d.width() - rsv - 3, ch, _hist.traffic, 4, true, HIST_SPAN, "now"); + info::chart(d, 1, f.at, d.width() - rsv - 3, ch, _hist.traffic, 4, true, HIST_SPAN, "now", info::axisPlain); } else if (f.place(lh + 6)) { info::spark(d, 1, f.at, d.width() - rsv - 3, lh + 3, _hist.traffic, 4); } @@ -352,6 +357,8 @@ public: } const info::History& battHistory() const { return _hist.batt; } + const info::History& noiseHistory() const { return _hist.noise; } + const info::History& trafficHistory() const { return _hist.traffic; } // Contacts heard from in the last hour (cached for 10 s: it walks them all). static int heardLastHour() { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 79ea4929..3b2c5fcf 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -232,21 +232,6 @@ static BigClock bigClockSize(const DisplayDriver& d) { if (sc < 1) sc = 1; return { sc, lettering::LOGO_H * sc }; } -static void drawLettering(DisplayDriver& d, int x, int y, const char* t, int sc, int gap) { - for (const char* p = t; *p; p++) { - const int cw = lettering::charW(*p); - if (cw < 0) continue; - for (int r = 0; r < lettering::LOGO_H; r++) - for (int col = 0; col < cw; col++) { - if (!lettering::inked(*p, col, r)) continue; - int e = col; - while (e + 1 < cw && lettering::inked(*p, e + 1, r)) e++; - d.fillRect(x + col * sc, y + r * sc, (e - col + 1) * sc, sc); - col = e; - } - x += cw * sc + gap; - } -} // The big clock's text, gaps and AM / PM, measured: its full width (w), the // digits' (dw). draw() puts its left edge at x. struct BigClockText { @@ -267,7 +252,7 @@ struct BigClockText { w = dw + (ap ? (int)d.getTextWidth(ap) + 2 * sc : 0); } void draw(DisplayDriver& d, int x, int y) const { - drawLettering(d, x, y, t, sc, gap); + info::lettering(d, x, y, t, sc, gap); if (!ap) return; d.setCursor(x + dw + 2 * sc, y + lettering::LOGO_H * sc - d.getLineHeight() + 1); d.print(ap); @@ -989,8 +974,12 @@ public: const int W = d.width(), H = d.height(), lh = d.getLineHeight(); const int s = miniIconScale(d); const bool tall = H - top > W; + // A screen 40 characters wide (the 4.2"): a square map and a column of + // labelled rows beside it, one line apart. + const bool wide = !tall && W >= 40 * d.getCharWidth(); const int rows_h = tall ? 4 * (lh + lh / 2) : 0; - const int pw = tall ? W : W / 2, ph = H - top - rows_h; // map panel + const int ph = H - top - rows_h; + const int pw = tall ? W : wide ? (ph < W * 3 / 5 ? ph : W * 3 / 5) : W / 2; // map panel d.setColor(DisplayDriver::LIGHT); d.drawSoftRect(0, top, pw, ph); if (!drawMapPreview(d, 2, top + 2, pw - 4, ph - 4)) { @@ -1006,6 +995,8 @@ public: d.print(why); } + if (wide) { drawMapColumn(d, pw + 6, top, W - 2); return; } + // The column: four rows, always, like the Status tiles. const int cx = tall ? 2 : pw + 3, cw = W - cx; const int row_h = tall ? rows_h / 4 : ph / 4; @@ -1049,6 +1040,72 @@ public: } } + // The wide Map page's column, x0..x1 from top: GPS and the position, the + // trail, then live contacts, the target and waypoints, each an icon, a + // label and the value at the right, the groups split by a dotted rule. + void drawMapColumn(DisplayDriver& d, int x0, int top, int x1) { + const int lh = d.getLineHeight(), s = miniIconScale(d), step = d.lineStep() + 2; + const int lx = x0 + PAGE_ICON_PX * s + 2 * s; // labels clear of the widest icon + int y = top + 1; + auto row = [&](const MiniIcon* ic, const char* label, const char* val) { + if (ic) miniIconDraw(d, x0, y + (lh - ic->h * s) / 2 - s, *ic); + d.setCursor(lx, y); d.print(label); + d.drawTextRightAlign(x1, y, val); + y += step; + }; + auto rule = [&]() { info::rule(d, x0, y + lh / 2 - 2, x1 - x0); y += lh / 2 + 2; }; + char buf[24]; +#if ENV_INCLUDE_GPS == 1 + LocationProvider* loc = sensors.getLocationProvider(); + const bool on = _task->getGPSState(); + if (!loc) strcpy(buf, "none"); + else if (!on) strcpy(buf, "off"); + else if (!loc->isValid()) strcpy(buf, "no fix"); + else snprintf(buf, sizeof(buf), "%ld sats", loc->satellitesCount()); + row(&ICON_GPS, "GPS", buf); + int32_t mla, mlo; + if (_task->currentLocation(mla, mlo)) { + snprintf(buf, sizeof(buf), "%.5f", mla / 1e6); row(nullptr, "Lat", buf); + snprintf(buf, sizeof(buf), "%.5f", mlo / 1e6); row(nullptr, "Lon", buf); + } +#else + row(&ICON_GPS, "GPS", "none"); +#endif + rule(); + TrailStore& tr = _task->trail(); + if (tr.empty() && !tr.isActive()) strcpy(buf, "-"); + else geo::fmtDist(buf, sizeof(buf), tr.totalDistanceMeters() / 1000.0f, _task->useImperial(), true); + row(&ICON_TRAIL, "Trail", buf); + if (!tr.empty() || tr.isActive()) { + const uint32_t t = tr.elapsedSeconds(); + snprintf(buf, sizeof(buf), "%luh %02lum", (unsigned long)(t / 3600), (unsigned long)(t / 60 % 60)); + row(nullptr, tr.isActive() ? (tr.isPaused() ? "Paused" : "Rec") : "Time", buf); + snprintf(buf, sizeof(buf), "%d", tr.count()); + row(nullptr, "Points", buf); + } + rule(); + const float km = statusDistanceKm(); + int32_t tla, tlo; + const bool target = _task->activeTargetPos(tla, tlo); + const int live = _task->liveTrack().active(rtc_clock.getCurrentTime()); + snprintf(buf, sizeof(buf), "%d", live); + row(&ICON_MAP_CONTACT, "Live", buf); + if (live > 0 && km >= 0 && !target) { + geo::fmtDist(buf, sizeof(buf), km, _task->useImperial(), true); + row(nullptr, "Nearest", buf); + } + if (target) { + if (km >= 0) geo::fmtDist(buf, sizeof(buf), km, _task->useImperial(), true); + else strcpy(buf, "-"); + row(&ICON_MAP_TARGET, "Target", buf); + } + snprintf(buf, sizeof(buf), "%d", _task->waypoints().count()); + row(&ICON_MAP_WAYPOINT, "Waypoints", buf); + } + + // The Favourites grid: landscape 3x2, portrait 2x3. + static int favCols(DisplayDriver& d) { return d.isLandscape() ? 3 : 2; } + // The clock fields set in Settings (dashboard_fields), in order; their count. int clockFields(uint8_t* out) { int n = 0; @@ -1090,14 +1147,21 @@ public: uint8_t fields[3]; const int nf = clockFields(fields); + // The month under the date, where it fits: a tall screen (portrait + // e-ink), or the 4.2" landscape one with the clock moved to the top. + const int fields_h = nf ? 2 * lh + 1 + lh / 2 : 0; + const int cal_h = calendar::height(d, ti); + bool cal = false; int date_y; if (d.height() > d.width()) { // portrait e-ink: stacked digits date_y = drawClockTall(d, lh / 2, &ti, h12); + cal = true; } else { const BigClock bc = bigClockSize(d); const int bar = 3 * bc.sc / 2; // the seconds bar and its gaps const int block = bc.h + bar + 1 + lh; - const int y = nf ? bc.sc : (H - block) / 2; + cal = bc.sc + block + lh / 2 + 2 + cal_h <= H - fields_h; + const int y = nf || cal ? bc.sc : (H - block) / 2; drawBigClock(d, W / 2, y, &ti, h12, bc.sc); const int bar_y = y + bc.h + bar / 2 + 1; if (show_sec) { // fills over the minute: dotted track, solid part @@ -1114,11 +1178,11 @@ public: snprintf(buf, sizeof(buf), "%s %d %s %d", WDAY[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon], 1900 + ti.tm_year); d.drawTextCentered(W / 2, date_y, buf); - // A tall screen (portrait e-ink): the month under the date, in the room - // above the clock fields. const int cal_top = date_y + lh + lh / 2 + 2; - const int cal_bottom = nf ? H - 2 * lh - 1 - lh / 2 : H; - if (H > W && calendar::height(d, ti) <= cal_bottom - cal_top) calendar::draw(d, 0, cal_top, W, ti, alarmDays(ti)); + if (cal && cal_h <= H - fields_h - cal_top) { // no wider than four characters a day + const int cw = W < 28 * d.getCharWidth() ? W : 28 * d.getCharWidth(); + calendar::draw(d, (W - cw) / 2, cal_top, cw, ti, alarmDays(ti)); + } if (nf == 0) return; refresh_sensors(); @@ -1158,6 +1222,9 @@ public: if (synced) localTm(unix_ts, _node_prefs ? _node_prefs->tz_offset_hours : 0, ti); const bool h12 = _node_prefs && _node_prefs->clock_12h; const bool compact = _node_prefs && _node_prefs->lock_compact && W > H; + // The month and the rows under the big clock: on a tall screen (portrait + // e-ink), and on one 40 characters wide (the 4.2"). + const bool extras = H > W || W >= 40 * d.getCharWidth(); char pill[24] = ""; if (compact) { @@ -1205,9 +1272,9 @@ public: char buf[16]; snprintf(buf, sizeof(buf), "%s %d %s", WDAY[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon]); d.drawTextCentered(W / 2, date_y, buf); - if (H > W) drawLockTallExtras(d, date_y + lh + lh / 2 + 2, hint ? pill_y - 3 : H, ti, unread); + if (extras) drawLockTallExtras(d, date_y + lh + lh / 2 + 2, hint ? pill_y - 3 : H, ti, unread); } - if (!hint && unread > 0 && !(synced && H > W)) snprintf(pill, sizeof(pill), "%d%s new", unread, _task->getAnyUnreadOverflow() ? "+" : ""); + if (!hint && unread > 0 && !(synced && extras)) snprintf(pill, sizeof(pill), "%d%s new", unread, _task->getAnyUnreadOverflow() ? "+" : ""); } if (hint) { @@ -1227,7 +1294,7 @@ public: d.setColor(DisplayDriver::LIGHT); } - // Under a tall lock clock (portrait e-ink), between y and bottom: the month, + // Under the lock clock on a tall or a 4.2" screen, between y and bottom: the month, // then a row each for unread messages, the armed alarm and the clock fields // -- the rows first, the calendar only if there's room for it as well. void drawLockTallExtras(DisplayDriver& d, int y, int bottom, const struct tm& ti, int unread) { @@ -1251,7 +1318,8 @@ public: } const int rows_h = n * step; if (calendar::height(d, ti) + d.getLineHeight() / 2 + rows_h <= bottom - y) { - calendar::draw(d, 0, y, W, ti, alarmDays(ti)); + const int cw = W < 28 * d.getCharWidth() ? W : 28 * d.getCharWidth(); // as on the Clock page + calendar::draw(d, (W - cw) / 2, y, cw, ti, alarmDays(ti)); y += calendar::height(d, ti) + d.getLineHeight() / 2; } for (int k = 0; k < n && y + d.getLineHeight() <= bottom; k++, y += step) { @@ -1262,30 +1330,51 @@ public: } // The Status page: four tiles split by dotted rules, each one subject's - // headline (icon + main value) over a detail line. Enter opens the Status - // screen with all of it. + // headline (icon + main value) over a detail line. A tile with the room + // (the 4.2" panel) adds a line and a chart of its history under it. Enter + // opens the Status screen with all of it. void drawStatusTiles(DisplayDriver& d, int top) { const int W = d.width(), H = d.height() - top; const int lh = d.getLineHeight(), s = miniIconScale(d); const int mid_x = W / 2, mid_y = top + H / 2; + const bool roomy = H / 2 >= 7 * lh; // three lines and a chart a tile + StatusScreen* st = (StatusScreen*)_task->statusScreen(); d.setColor(DisplayDriver::LIGHT); info::vrule(d, mid_x, top, H); info::rule(d, 0, mid_y, W); - auto tile = [&](int col, int row, const MiniIcon* ic, float batt, const char* head, const char* detail) { - const int x = col ? mid_x + 3 : 1, y = row ? mid_y + 2 : top + 1; - const int w = (col ? W - x : mid_x - 2 - x); + // The tile's box, inside the rules. + auto box = [&](int col, int row, int& x, int& y, int& w, int& h) { + x = col ? mid_x + 3 : 1; y = row ? mid_y + 2 : top + 1; + w = col ? W - x : mid_x - 2 - x; + h = (row ? top + H : mid_y - 1) - y; + }; + auto tile = [&](int col, int row, const MiniIcon* ic, float batt, const char* head, const char* detail, + const char* more = "") { + int x, y, w, h; + box(col, row, x, y, w, h); const int iy = y + (lh - info::batteryH(d)) / 2 - s; int tx = x; if (ic) { miniIconDraw(d, x, iy, *ic); tx = x + ic->w * s + 2 * s; } else if (batt >= 0) { info::battery(d, x, iy, batt); tx = x + info::batteryW(d) + 2 * s; } d.drawTextEllipsized(tx, y, x + w - tx, head); d.drawTextEllipsized(x, y + lh + 2, w, detail); + if (roomy && more[0]) d.drawTextEllipsized(x, y + 2 * (lh + 2), w, more); }; - char h[20], t[20]; + // Under a roomy tile's three lines: a history. + auto tileChart = [&](int col, int row, const info::History& hs, int min_span, bool bars) { + int x, y, w, h; + box(col, row, x, y, w, h); + const int cy = y + 3 * (lh + 2) + 2, ch = y + h - cy - 3; + if (ch >= 2 * lh) info::chart(d, x, cy, w - 4, ch, hs, min_span, bars, nullptr, nullptr); + }; + char h[20], t[20], m[28]; snprintf(h, sizeof(h), "%.3f", _node_prefs->freq); snprintf(t, sizeof(t), "SF%u %gk", (unsigned)_node_prefs->sf, _node_prefs->bw); - tile(0, 0, &ICON_PG_RADIO, -1, h, t); + const int nf = (int)radio_driver.getNoiseFloor(); + if (nf) snprintf(m, sizeof(m), "Noise %d dBm", nf); else m[0] = 0; + tile(0, 0, &ICON_PG_RADIO, -1, h, t, m); + if (roomy && st) tileChart(0, 0, st->noiseHistory(), 6, false); #if ENV_INCLUDE_GPS == 1 { @@ -1304,7 +1393,10 @@ public: #endif } if (loc && on) snprintf(t, sizeof(t), "%ld sats", loc->satellitesCount()); - tile(1, 0, &ICON_GPS, -1, h, t); + int32_t la, lo; + m[0] = 0; + if (loc && on && loc->isValid() && _task->currentLocation(la, lo)) snprintf(m, sizeof(m), "%.4f %.4f", la / 1e6, lo / 1e6); + tile(1, 0, &ICON_GPS, -1, h, t, m); } #else tile(1, 0, &ICON_GPS, -1, "No GPS", ""); @@ -1315,14 +1407,18 @@ public: snprintf(h, sizeof(h), "%d%%", pct); snprintf(t, sizeof(t), "%d.%02d V%s", mv / 1000, (mv % 1000) / 10, board.isExternalPowered() ? " USB" : ""); tile(0, 1, nullptr, pct / 100.0f, h, t); - if (StatusScreen* st = (StatusScreen*)_task->statusScreen()) { // the battery's trend beside its % + if (st && roomy) tileChart(0, 1, st->battHistory(), 20, false); + else if (st) { // the battery's trend beside its % const int x0 = 1 + info::batteryW(d) + 2 * s + d.getTextWidth(h) + 3 * s, x1 = mid_x - 3; if (x1 - x0 >= 12) info::spark(d, x0, mid_y + 2, x1 - x0, lh - 1, st->battHistory(), 20); } - snprintf(h, sizeof(h), "%d nodes", the_mesh.getNumContacts()); + const int nc = the_mesh.getNumContacts(); + snprintf(h, sizeof(h), "%d node%s", nc, nc == 1 ? "" : "s"); snprintf(t, sizeof(t), "%d in 1 h", StatusScreen::heardLastHour()); - tile(1, 1, &ICON_MAP_CONTACT, -1, h, t); + snprintf(m, sizeof(m), "RX %lu TX %lu", (unsigned long)StatusScreen::totalRx(), (unsigned long)StatusScreen::totalTx()); + tile(1, 1, &ICON_MAP_CONTACT, -1, h, t, m); + if (roomy && st) tileChart(1, 1, st->trafficHistory(), 4, true); } // Small 5x5 glyph shown in the page-indicator row for each HomePage. @@ -1378,6 +1474,9 @@ public: int hh = lt.tm_hour; if (_node_prefs->clock_12h) { hh %= 12; if (hh == 0) hh = 12; } snprintf(filtered_name, sizeof(filtered_name), "%d:%02d", hh, lt.tm_min); + if (display.width() >= 40 * display.getCharWidth()) // the 4.2": the date beside it + snprintf(filtered_name + strlen(filtered_name), sizeof(filtered_name) - strlen(filtered_name), + " %s %d %s", WDAY[lt.tm_wday], lt.tm_mday, MON[lt.tm_mon]); } else { display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); } @@ -1473,7 +1572,7 @@ public: display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); - const int cols = display.isLandscape() ? 3 : 2; + const int cols = favCols(display); const int rows = NodePrefs::FAVOURITES_COUNT / cols; const int grid_y = content_y + 4; // the gap under the page icons, as on every page const int grid_h = display.height() - grid_y; @@ -1508,6 +1607,8 @@ public: bool resolved = false; uint32_t last_ts = 0; // heard from / last post, for a tall card int32_t plat = 0, plon = 0; // a contact's advertised position + const char* last_text = nullptr; // the newest message, for a big card + bool last_out = false; if (prefix && _task->favouriteSlotKind(i) == NodePrefs::FAV_KIND_CHANNEL) { uint8_t ch_idx = prefix[0]; @@ -1523,7 +1624,7 @@ public: MessageHistory& h = _task->core().history; for (int j = 0; j < h.chHistCount(); j++) { const ChHistEntry& e = h.chAtPos(h.chHistPosNewest(j)); - if (e.ch_idx == ch_idx) { last_ts = e.timestamp; break; } + if (e.ch_idx == ch_idx) { last_ts = e.timestamp; last_text = e.text; break; } } } } else if (prefix) { @@ -1537,6 +1638,9 @@ public: resolved = true; last_ts = c.lastmod; plat = c.gps_lat; plon = c.gps_lon; + MessageHistory& h = _task->core().history; + const int rp = h.dmHistEntryForContact(c.id.pub_key, 0); + if (rp >= 0) { last_text = h.dmAtPos(rp).text; last_out = h.dmAtPos(rp).outgoing; } break; } } @@ -1566,7 +1670,41 @@ public: if (sel && r > 0) mq_delay = r; if (unread > 0) display.drawUnreadBadge(tx + tw - 3, name_y, unread, sel, overflow); - if (tall) { + // A card six lines tall on a screen 40 characters wide (the 4.2"): + // the newest message under the name, how long ago and how far along + // the bottom. + if (th >= 6 * line_h && display.width() >= 40 * display.getCharWidth()) { + display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); + const int by = ty + th - line_h - 3; // the bottom line + const uint32_t now = rtc_clock.getCurrentTime(); + char buf[16]; + if (last_ts) { + geo::fmtAgeShort(buf, sizeof(buf), now, last_ts); + display.setCursor(tx + 3, by); display.print(buf); + } + int32_t mla, mlo; + if ((plat || plon) && _task->currentLocation(mla, mlo)) { + geo::fmtDist(buf, sizeof(buf), geo::haversineKm(mla, mlo, plat, plon), _task->useImperial(), true); + display.drawTextRightAlign(tx + tw - 3, by, buf); + } + const int my = name_y + line_h + line_h / 3; // the message, wrapped + const int nl_max = (by - 2 - my) / line_h; + if (nl_max > 0) { + char pv[MSG_TEXT_BUF + 4], tr_pv[MSG_TEXT_BUF + 4]; + if (last_text) snprintf(pv, sizeof(pv), "%s%s", last_out ? "Me: " : "", last_text); + else snprintf(pv, sizeof(pv), "No messages"); + display.translateUTF8ToBlocks(tr_pv, pv, sizeof(tr_pv)); + char lines[5][FS_CHARS_MAX]; + const int cap = nl_max < 4 ? nl_max : 4; + int n = FullscreenMsgView::wrapLines(display, tr_pv, tw - 6, lines, cap + 1); + if (n > cap) { // more than fits: the last line shown ends in an ellipsis + n = cap; + const size_t l = strlen(lines[n - 1]); + if (l + 4 < sizeof(lines[0])) strcat(lines[n - 1], " ..."); + } + for (int k = 0; k < n; k++) display.drawTextEllipsized(tx + 3, my + k * line_h, tw - 6, lines[k]); + } + } else if (tall) { display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); // the badge leaves its own const int s = miniIconScale(display), gap = line_h / 3; int y = name_y + line_h + gap; @@ -1645,7 +1783,7 @@ public: return true; } DisplayDriver* d = _task->getDisplay(); - const int cols = (d && d->isLandscape()) ? 3 : 2; + const int cols = d ? favCols(*d) : 2; const int rows = NodePrefs::FAVOURITES_COUNT / cols; int col = _fav_sel % cols; int row = _fav_sel / cols;