diff --git a/examples/companion_radio/ui-new/CalendarView.h b/examples/companion_radio/ui-new/CalendarView.h new file mode 100644 index 00000000..e30683e0 --- /dev/null +++ b/examples/companion_radio/ui-new/CalendarView.h @@ -0,0 +1,60 @@ +#pragma once +// The current month as a grid of days, weeks from Monday, today in a filled +// soft box, marked days (the alarm's) underlined. For the room a tall e-ink +// screen has under its clock (the Clock page, the lock screen). + +#include +#include +#include "InfoKit.h" + +namespace calendar { + +inline int daysIn(int year, int mon) { // mon 0..11, year as in tm (since 1900) + static const uint8_t D[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; + const int y = year + 1900; + const bool leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0; + return D[mon] + (mon == 1 && leap ? 1 : 0); +} +// The column (0 = Monday) the 1st falls in. +inline int firstCol(const struct tm& t) { + const int wday1 = ((t.tm_wday - (t.tm_mday - 1)) % 7 + 7) % 7; // 0 = Sunday + return (wday1 + 6) % 7; +} +inline int weeks(const struct tm& t) { return (firstCol(t) + daysIn(t.tm_year, t.tm_mon) + 6) / 7; } + +// Its height: the day names, a rule, then a row a week. +inline int height(DisplayDriver& d, const struct tm& t) { + return (1 + weeks(t)) * (d.getLineHeight() + 2) + 3; +} + +// `marked`: bit (day - 1) set for each day to underline. +inline void draw(DisplayDriver& d, int x, int y, int w, const struct tm& t, uint32_t marked = 0) { + static const char* const NAMES[] = { "Mo", "Tu", "We", "Th", "Fr", "Sa", "Su" }; + const int lh = d.getLineHeight(), step = lh + 2, cw = w / 7; + const int x0 = x + (w - 7 * cw) / 2; + d.setColor(DisplayDriver::LIGHT); + for (int c = 0; c < 7; c++) d.drawTextCentered(x0 + c * cw + cw / 2, y, NAMES[c]); + info::rule(d, x0, y + lh + 1, 7 * cw); + const int top = y + step + 3, col0 = firstCol(t), n = daysIn(t.tm_year, t.tm_mon); + char buf[3]; + for (int day = 1; day <= n; day++) { + const int k = col0 + day - 1, cx = x0 + (k % 7) * cw, cy = top + (k / 7) * step; + snprintf(buf, sizeof(buf), "%d", day); + if (day == t.tm_mday) { + d.fillSoftRect(cx + 1, cy - 1, cw - 2, lh + 2); + d.setColor(DisplayDriver::DARK); + d.drawTextCentered(cx + cw / 2, cy, buf); + d.setColor(DisplayDriver::LIGHT); + } else { + d.drawTextCentered(cx + cw / 2, cy, buf); + } + if (marked & (1UL << (day - 1))) { // under the number, in the gap below it + const int uw = d.getTextWidth(buf); + d.setColor(day == t.tm_mday ? DisplayDriver::DARK : DisplayDriver::LIGHT); + d.fillRect(cx + (cw - uw) / 2, cy + lh, uw, 1); + d.setColor(DisplayDriver::LIGHT); + } + } +} + +} // namespace calendar diff --git a/examples/companion_radio/ui-new/InfoKit.h b/examples/companion_radio/ui-new/InfoKit.h index 95f7d0c9..a145c443 100644 --- a/examples/companion_radio/ui-new/InfoKit.h +++ b/examples/companion_radio/ui-new/InfoKit.h @@ -7,6 +7,7 @@ #include #include +#include #include "icons.h" namespace info { @@ -80,6 +81,77 @@ inline void meter(DisplayDriver& d, int x, int y, int w, int h, float frac) { if (fw > 0) d.fillRect(x + in, y + in, fw, h - 2 * in); } +// Midpoint circle; `step` > 1 dots it (every step-th point of each octant). +inline void circle(DisplayDriver& d, int cx, int cy, int r, int step = 1) { + int x = r, y = 0, err = 1 - r, i = 0; + while (x >= y) { + if (i++ % step == 0) { + d.fillRect(cx + x, cy + y, 1, 1); d.fillRect(cx - x, cy + y, 1, 1); + d.fillRect(cx + x, cy - y, 1, 1); d.fillRect(cx - x, cy - y, 1, 1); + d.fillRect(cx + y, cy + x, 1, 1); d.fillRect(cx - y, cy + x, 1, 1); + d.fillRect(cx + y, cy - x, 1, 1); d.fillRect(cx - y, cy - x, 1, 1); + } + y++; + if (err < 0) err += 2 * y + 1; + else { x--; err += 2 * (y - x) + 1; } + } +} + +// A 1 px line (Bresenham). +inline void line(DisplayDriver& d, int x0, int y0, int x1, int y1) { + const int dx = x1 > x0 ? x1 - x0 : x0 - x1, dy = y1 > y0 ? y0 - y1 : y1 - y0; + const int sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1; + int err = dx + dy; + for (;;) { + d.fillRect(x0, y0, 1, 1); + if (x0 == x1 && y0 == y1) break; + const int e2 = 2 * err; + if (e2 >= dy) { err += dy; x0 += sx; } + if (e2 <= dx) { err += dx; y0 += sy; } + } +} + +// An arrow of length 2r centred on (cx, cy), pointing to `deg` (0 = up / +// north, clockwise): a shaft and a two-stroke head at the tip. +inline void arrow(DisplayDriver& d, int cx, int cy, int r, int deg) { + const float a = deg * (float)M_PI / 180.0f, sx = sinf(a), sy = -cosf(a); + const int tx = cx + (int)lroundf(r * sx), ty = cy + (int)lroundf(r * sy); + line(d, cx - (int)lroundf(r * sx), cy - (int)lroundf(r * sy), tx, ty); + const float h = r * 0.6f; + for (int k = -1; k <= 1; k += 2) { // the head's strokes, 30 degrees off the shaft + const float b = a + (float)M_PI + k * 0.52f; + line(d, tx, ty, tx + (int)lroundf(h * sinf(b)), ty - (int)lroundf(h * cosf(b))); + } +} + +// A compass rose of radius r round (cx, cy), north up: the ring, a tick in at +// each cardinal point, N in a gap at the top, and -- for deg >= 0 -- a needle +// from the centre to the ring, towards deg. +inline void rose(DisplayDriver& d, int cx, int cy, int r, int deg) { + const int s = miniIconScale(d), t = 3 * s, lh = d.getLineHeight(), cw = d.getCharWidth(); + circle(d, cx, cy, r); + circle(d, cx, cy, r / 2, 3); + d.fillRect(cx - s / 2, cy + r - t, s, t); // S + d.fillRect(cx + r - t, cy - s / 2, t, s); // E + d.fillRect(cx - r, cy - s / 2, t, s); // W + d.setColor(DisplayDriver::DARK); // N, on a gap in the ring + d.fillRect(cx - cw / 2 - 1, cy - r - lh / 2, cw + 2, lh); + d.setColor(DisplayDriver::LIGHT); + d.setCursor(cx - cw / 2, cy - r - lh / 2); + d.print("N"); + if (deg >= 0) { + const float a = deg * (float)M_PI / 180.0f; + const int tx = cx + (int)lroundf((r - t - s) * sinf(a)), ty = cy - (int)lroundf((r - t - s) * cosf(a)); + for (int o = -(s / 2); o <= s / 2; o++) line(d, cx + o, cy, tx + o, ty); + const float h = r * 0.3f; + for (int k = -1; k <= 1; k += 2) { + const float b = a + (float)M_PI + k * 0.45f; + line(d, tx, ty, tx + (int)lroundf(h * sinf(b)), ty - (int)lroundf(h * cosf(b))); + } + } + d.fillRect(cx - s, cy - s, 2 * s + 1, 2 * s + 1); // the centre: you +} + // A chip: a state as a filled pill (FIX, REC), a parameter as an outline // (SF8). One line plus 2 px tall; returns the x after it, gap included. inline int chipW(DisplayDriver& d, const char* t) { return d.getTextWidth(t) + 5; } @@ -200,6 +272,70 @@ inline void spark(DisplayDriver& d, int x, int y, int w, int h, const History } } +// Ordered (Bayer 4x4) dithering: whether pixel (x, y) is ink at a density of +// level/16. For graded fills on large elements only -- it breaks up small +// ones. +inline bool bayer(int x, int y, int level) { + static const uint8_t M[4][4] = { { 0, 8, 2, 10 }, { 12, 4, 14, 6 }, { 3, 11, 1, 9 }, { 15, 7, 13, 5 } }; + return M[y & 3][x & 3] < level; +} +// A column from y0 down to (not including) y1, dithered from dense at the top +// to sparse at the bottom: the glow under a chart's line. +inline void fadeColumn(DisplayDriver& d, int x, int y0, int y1) { + const int h = y1 - y0; + for (int y = y0; y < y1; y++) { + const int level = 9 - 8 * (y - y0) / (h > 1 ? h : 1); // 9/16 under the line -> 1/16 at the floor + if (bayer(x, y, level)) d.fillRect(x, y, 1, 1); + } +} + +// A history as a chart, for a screen with the room (e-ink): a soft frame with +// a dotted middle line, the samples as a step line over a dithered glow -- +// 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. +inline int chartH(DisplayDriver& d, int frame_h) { return frame_h + d.getLineHeight() + 3; } +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) { + 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; + d.setColor(DisplayDriver::DARK); + d.fillRect(tx - 2, ty, tw + 4, d.getLineHeight()); + 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++) { + const int cx = ix + iw - 1 - (hs.n - 1 - i) * (iw - 1) / (N - 1); + const int cy = iy + ih - 1 - (hs.at(i) - lo) * (ih - 1) / (hi - lo); + if (bars) { + if (hs.at(i) > lo) d.fillRect(cx - bw + 1, cy, bw, iy + ih - cy); + } else { + if (px >= 0) { + d.fillRect(px, py, cx - px + 1, 1); + d.fillRect(cx, py < cy ? py : cy, 1, (py < cy ? cy - py : py - cy) + 1); + for (int c = px + 1; c <= cx; c++) fadeColumn(d, c, (c < cx ? py : cy) + 2, iy + ih); + } + px = cx; py = cy; + } + } + } + const int ty = y + h + 2; + if (left && *left) { d.setCursor(x, ty); d.print(left); } + if (right && *right) d.drawTextRightAlign(x + w, ty, right); +} + // 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/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 067d4229..effdba87 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -1,4 +1,6 @@ #pragma once +#include "InfoKit.h" +#include "../Features.h" #include "../GeoUtils.h" #include "NavView.h" #include "TabBar.h" @@ -461,6 +463,46 @@ class NearbyScreen : public UIScreen, protected NearbyModel { 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, display.width() - 4, buf); + + const int W = display.width(), H = display.height(), lh = display.getLineHeight(); + int rows = 5; + // How a message gets there, where there's a line for it (e-ink): a chain + // from you to it, a ring per repeater on the way. + ContactInfo ci; + if (hdr + step * 5 + lh <= H && e.contact_idx >= 0 && the_mesh.getContactByIdx(e.contact_idx, ci)) { + const int y = hdr + step * 5; + display.setCursor(2, y); + display.print("Path:"); + int x = 2 + display.getTextWidth("Path: "); + if (ci.out_path_len == 0xFF) { + display.print(" flood"); + } else { + const int hops = ci.out_path_len & 63, shown = hops > 5 ? 5 : hops; + const int r = lh / 4 > 2 ? lh / 4 : 2, cy = y + lh / 2 - 1, gap = r + 3; + 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); + info::circle(display, x + r, cy, r); + if (k == 0 || k == shown + 1) display.fillRect(x + 1, cy - r + 1, 2 * r - 1, 2 * r - 1); + x += 2 * r + 1 + gap; + } + if (hops == 0) snprintf(buf, sizeof(buf), "direct"); + else snprintf(buf, sizeof(buf), "%d hop%s", hops, hops == 1 ? "" : "s"); + display.setCursor(x, y); + display.print(buf); + } + rows = 6; + } + + // 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 (e.dist_km < 0.0f) return; + const int below = hdr + 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 (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)); } void renderScanDetail(DisplayDriver& display) { @@ -608,7 +650,9 @@ public: } int item_h = display.lineStep(); - int dist_col = display.width() - display.getCharWidth() * 7; + // E-ink sets an arrow towards the node before its distance: one more cell. + const bool arrows = Features::IS_EINK && _source == SRC_STORED && _sort != SORT_TIME; + int dist_col = display.width() - display.getCharWidth() * (arrows ? 9 : 7); display.setColor(DisplayDriver::LIGHT); const char* flt = (_filter != F_ALL) ? filterLabel(_filter) : nullptr; @@ -691,6 +735,12 @@ public: else strcpy(right, "-"); // no fix of ours or theirs } if (right[0]) display.drawTextRightAlign(display.width() - 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 + - ic.w * miniIconScale(display), y, ic); + } }); } diff --git a/examples/companion_radio/ui-new/SkyView.h b/examples/companion_radio/ui-new/SkyView.h index 0703690c..c162368f 100644 --- a/examples/companion_radio/ui-new/SkyView.h +++ b/examples/companion_radio/ui-new/SkyView.h @@ -22,6 +22,7 @@ #include #endif #include "icons.h" // miniIconScale() +#include "InfoKit.h" // info::circle() namespace skyview { @@ -38,20 +39,6 @@ namespace skyview { inline void dot(DisplayDriver& d, int x, int y) { d.fillRect(x, y, 1, 1); } - // Midpoint circle; `step` > 1 dots it (every step-th point of each octant). - inline void circle(DisplayDriver& d, int cx, int cy, int r, int step = 1) { - int x = r, y = 0, err = 1 - r, i = 0; - while (x >= y) { - if (i++ % step == 0) { - dot(d, cx + x, cy + y); dot(d, cx - x, cy + y); dot(d, cx + x, cy - y); dot(d, cx - x, cy - y); - dot(d, cx + y, cy + x); dot(d, cx - y, cy + x); dot(d, cx + y, cy - x); dot(d, cx - y, cy - x); - } - y++; - if (err < 0) err += 2 * y + 1; - else { x--; err += 2 * (y - x) + 1; } - } - } - inline int usedCount(const GpsSky& g) { int n = 0; for (int i = 0; i < g.count; i++) if (g.used(g.sats[i])) n++; @@ -62,11 +49,15 @@ namespace skyview { const int lh = d.getLineHeight(); const int cw = d.getCharWidth(); const int s = miniIconScale(d); - const int r = (d.height() - 1 - top) / 2 - 1; - const int cx = r + 1, cy = top + r + 1; + // 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; - circle(d, cx, cy, r); // the horizon - circle(d, cx, cy, r / 2, 2); // 45 degrees up, dotted + info::circle(d, cx, cy, r); // the horizon + info::circle(d, cx, cy, r / 2, 2); // 45 degrees up, dotted dot(d, cx, cy); // straight up d.setColor(DisplayDriver::DARK); // north, on a gap in the rim d.fillRect(cx - cw / 2 - 1, cy - r - lh / 2, cw + 2, lh); @@ -95,9 +86,9 @@ namespace skyview { } } - // The numbers, right of the plot. - const int x0 = 2 * r + 6, step = lh + 1; - int y = top; + // The numbers, right of the plot (or under it). + const int x0 = stacked ? 1 : 2 * r + 6, step = lh + 1; + int y = stacked ? cy + r + lh / 2 + 2 : top; char buf[24]; d.setCursor(x0, y); d.print(g.fix_mode == 3 ? "3D fix" : g.hasFix() ? "2D fix" : "No fix"); diff --git a/examples/companion_radio/ui-new/StatusScreen.h b/examples/companion_radio/ui-new/StatusScreen.h index 6cc217c6..00e1c883 100644 --- a/examples/companion_radio/ui-new/StatusScreen.h +++ b/examples/companion_radio/ui-new/StatusScreen.h @@ -48,11 +48,28 @@ private: CayenneLPP _lpp; unsigned long _lpp_at = 0; - // History lines, one sample a minute (32 min). - static const unsigned long SAMPLE_MS = 60000; + // History lines, one sample a minute (32 min). E-ink draws them as charts + // (chartBlock()) over a longer span: a sample every 5 minutes, 8 hours. + static const int HIST_N = Features::IS_EINK ? 96 : 32; + static const unsigned long SAMPLE_MS = Features::IS_EINK ? 300000 : 60000; + static constexpr const char* HIST_SPAN = Features::IS_EINK ? "8 h ago" : "32 min"; unsigned long _next_sample = 0; uint32_t _last_rx = 0; - info::History<32> _noise, _batt, _traffic; + info::History _noise, _batt, _traffic; + // The SNR of each packet heard, the last 32 (e-ink's Radio chart), x4 dB. + info::History<32> _snr; + uint32_t _snr_rx = 0; + + // A chart as a block (e-ink): `title` with the value now on its line, the + // 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 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"); + } void addTab(uint8_t t, const char* label) { _tabs[_count] = t; _labels[_count] = label; _count++; } uint8_t tab() const { return _tabs[_cur]; } @@ -104,9 +121,11 @@ private: } snprintf(a, sizeof(a), "%d dBm", (int)radio_driver.getTxPower()); row(d, f, rsv, "TX power", a); - if (f.place(d.lineStep())) { // label, the last half hour, the value now - const int nf = (int)radio_driver.getNoiseFloor(); - if (nf) snprintf(a, sizeof(a), "%d dBm", nf); else strcpy(a, "-"); + 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, _noise, 6, false, HIST_SPAN); + } 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; const int x1 = d.width() - rsv - 2 - d.getTextWidth(a) - 6; @@ -119,7 +138,8 @@ 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()); - row(d, f, rsv, "Last SNR", b); + if (Features::IS_EINK) chartBlock(d, f, rsv, "SNR", b, _snr, 20, false, "32 pkts"); + else row(d, f, rsv, "Last SNR", b); } #if ENV_INCLUDE_GPS == 1 @@ -188,7 +208,9 @@ private: snprintf(a, sizeof(a), "%d.%02d V", mv / 1000, (mv % 1000) / 10); row(d, f, rsv, "Battery", a); row(d, f, rsv, "Source", board.isExternalPowered() ? "USB" : "Battery"); - if (f.place(d.lineStep())) { + if (Features::IS_EINK) { + chartBlock(d, f, rsv, "Trend", nullptr, _batt, 20, false, HIST_SPAN); + } else if (f.place(d.lineStep())) { d.setCursor(1, f.at); d.print("Trend"); const int x0 = 1 + d.getTextWidth("Trend") + 6; @@ -229,7 +251,13 @@ private: snprintf(a, sizeof(a), "TX %lu", (unsigned long)totalTx()); d.drawTextRightAlign(d.width() - rsv - 2, f.at, a); } - if (f.place(lh + 6)) info::spark(d, 1, f.at, d.width() - rsv - 3, lh + 3, _traffic, 4); + 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, _traffic, 4, true, HIST_SPAN, "now"); + } else if (f.place(lh + 6)) { + info::spark(d, 1, f.at, d.width() - rsv - 3, lh + 3, _traffic, 4); + } static const uint8_t MSG[] = { PAYLOAD_TYPE_TXT_MSG, PAYLOAD_TYPE_GRP_TXT }; static const uint8_t ADVERT[] = { PAYLOAD_TYPE_ADVERT }; @@ -319,6 +347,7 @@ public: // Called from the UI loop on every pass: one sample a minute for the lines. void sample() { + if (Features::IS_EINK) samplePacket(); const unsigned long now = millis(); if (_next_sample && (long)(now - _next_sample) < 0) return; _next_sample = now + SAMPLE_MS; @@ -329,8 +358,13 @@ public: if (_last_rx || rx) _traffic.push((int16_t)(rx - _last_rx > 30000 ? 30000 : rx - _last_rx)); _last_rx = rx; } + // On every pass: the SNR of a packet just heard (e-ink's Radio chart). + void samplePacket() { + const uint32_t rx = totalRx(); + if (rx != _snr_rx) { _snr.push((int16_t)(radio_driver.getLastSNR() * 4)); _snr_rx = rx; } + } - const info::History<32>& battHistory() const { return _batt; } + const info::History& battHistory() const { return _batt; } // 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/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index 3ad11395..e5c2bf56 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -547,6 +547,68 @@ private: summaryBlocks(display, f, rsv); _summary_max_scroll = f.more ? _summary_scroll + 1 : _summary_scroll; if (rsv) drawScrollIndicatorPx(display, top, view, f.total_px, view, f.skipped_px); + // A tall screen (portrait e-ink) has room left under the numbers: the map + // goes there, below a dotted rule, and the height profile under it when + // the points carry altitude. + else if (bottom - f.y >= 6 * display.lineStep()) { + info::rule(display, 1, f.y + 2, display.width() - 2); + const int ph = hasAltitude() ? 4 * display.getLineHeight() : 0; + renderMap(display, f.y + 4, bottom - 2 - ph); + if (ph) renderProfile(display, 1, bottom - ph, display.width() - 2, ph); + } + } + + bool hasAltitude() const { + for (int i = 0; i < _store->count(); i++) if (_store->at(i).alt_m != TRAIL_ALT_NONE) return true; + return false; + } + + // The height over the distance walked, in x, y, w, h: a framed area, a + // solid line over a dithered glow, then the climb so far and the highest + // point under it. + void renderProfile(DisplayDriver& d, int x, int y, int w, int h) { + const int lh = d.getLineHeight(), fh = h - lh - 2; // the frame; the caption under it + const int n = _store->count(); + int lo = 32767, hi = -32768, gain = 0, last = TRAIL_ALT_NONE; + for (int i = 0; i < n; i++) { + const int a = _store->at(i).alt_m; + if (a == TRAIL_ALT_NONE) continue; + if (a < lo) lo = a; + if (a > hi) hi = a; + if (last == TRAIL_ALT_NONE || a < last) last = a; // the climb, past 3 m of jitter + else if (a - last >= 3) { gain += a - last; last = a; } + } + if (hi - lo < 20) { const int mid = (hi + lo) / 2; lo = mid - 10; hi = mid + 10; } + const float total = _store->totalDistanceMeters() > 0 ? (float)_store->totalDistanceMeters() : 1.0f; + d.drawSoftRect(x, y, w, fh); + const int ix = x + 2, iy = y + 2, iw = w - 4, ih = fh - 4; + float cum = 0; + int px = -1, py = 0; + for (int i = 0; i < n; i++) { + const TrailPoint& p = _store->at(i); + if (i > 0 && !(p.flags & TRAIL_FLAG_SEG_START)) + cum += TrailStore::haversineMeters(_store->at(i - 1).lat_1e6, _store->at(i - 1).lon_1e6, p.lat_1e6, p.lon_1e6); + if (p.alt_m == TRAIL_ALT_NONE) continue; + int cx = ix + (int)(cum / total * (iw - 1)); + if (cx > ix + iw - 1) cx = ix + iw - 1; + const int cy = iy + ih - 1 - (p.alt_m - lo) * (ih - 1) / (hi - lo); + if (px >= 0) { + for (int c = px + 1; c <= cx; c++) { // each column: the line, the fill under it + const int ly = py + (cy - py) * (c - px) / (cx - px); + d.fillRect(c, ly, 1, 1); + info::fadeColumn(d, c, ly + 2, iy + ih); + } + } + px = cx; py = cy; + } + char a[16], b[16]; + snprintf(a, sizeof(a), "+%d m", gain); + snprintf(b, sizeof(b), "max %d m", hi); + const int cy = y + fh + 2; + miniIconDraw(d, x, cy, ICON_ARROW_NE); + d.setCursor(x + ICON_ARROW_NE.w * miniIconScale(d) + 2, cy); + d.print(a); + d.drawTextRightAlign(x + w, cy, b); } void renderList(DisplayDriver& display) { @@ -707,9 +769,10 @@ private: } } - void renderMap(DisplayDriver& display) { - const int top = display.listStart(); - const int bottom = display.height() - 2; + // The map between top and bottom (default: the whole view under the header). + void renderMap(DisplayDriver& display, int top = -1, int bottom = -1) { + if (top < 0) top = display.listStart(); + if (bottom < 0) bottom = display.height() - 2; WaypointStore& wp = _task->waypoints(); const int nwp = wp.count(); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 5300dc6e..af849b68 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -182,6 +182,7 @@ static const telemetry::Style L1_INFO = { "\xc2\xb0", true, false, 5 }; #endif #include "ToolsScreen.h" #include "ClockToolsScreen.h" // Alarm / Timer / Stopwatch (Clock page › Enter) +#include "CalendarView.h" // the month under a tall clock #include "../ui-core/Battery.h" @@ -334,10 +335,25 @@ class HomeScreen : public UIScreen { uint8_t _slide_from = 0; unsigned long _slide_t0 = 0; + // A wide screen (landscape e-ink) sets the page icons on the header line, + // between the time and the status icons, instead of on a row of their own. + // Measured for every page at full spacing, so it doesn't flip as pages are + // shown or hidden. + static int stripHalfW(DisplayDriver& d) { + const int s = miniIconScale(d); + return (10 * s * ((int)Count - 1) + 5 * s + 8) / 2; + } + static bool stripOnHeader(DisplayDriver& d) { + const int mid = d.width() / 2, half = stripHalfW(d); + // Left: the time and a gap. Right: the battery icon and two status icons. + const int right_need = 2 * d.getLineHeight() + 6 + 2 * (d.getCharWidth() + 3); + return mid - half >= 6 * d.getCharWidth() && mid + half <= d.width() - right_need; + } // First row under the header and the page-icon row (see render()). static int contentTop(DisplayDriver& d) { - const int pg_half = (5 * miniIconScale(d) + 1) / 2; - return d.getLineHeight() + 2 * pg_half + 4; + const int pg_half = (5 * miniIconScale(d) + 1) / 2, lh = d.getLineHeight(); + if (stripOnHeader(d)) return (lh > 2 * pg_half + 2 ? lh : 2 * pg_half + 2) + 3; + return lh + 2 * pg_half + 4; } // 0..1 eased progress of the running slide, or -1 when none. float slideProgress() { @@ -362,7 +378,7 @@ 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 + // ── Quick panels: an icon page as a few rows with margins round // them, the last one opening the full screen. Up/Down picks a row, Enter // acts on it. The rows are the Settings list's own (value, switch, bar). enum QKind : uint8_t { Q_VALUE, Q_SWITCH, Q_BAR, Q_BADGE, Q_MORE }; @@ -380,6 +396,15 @@ class HomeScreen : public UIScreen { uint8_t prefix[4]; char name[24]; // QA_CHAT: the label's storage }; + static const int QUICK_MAX = 8; + // How many rows a quick panel has room for (at least the 3 a 128x64 OLED + // fits): a taller screen lists more recent chats. + int quickFit() const { + DisplayDriver* d = _task->getDisplay(); + if (!d) return 3; + const int n = (d->height() - contentTop(*d) - 5) / (d->lineStep() + 1); + return n < 3 ? 3 : n > QUICK_MAX ? QUICK_MAX : n; + } uint8_t _quick_sel = 0; bool _quick_edit = false; // the selected row is being changed with Left/Right bool _quick_dirty = false; // ...and has moved: saved once when it ends @@ -419,12 +444,13 @@ class HomeScreen : public UIScreen { buf[n - 1] = 0; } - // The two newest conversations, DM / room or channel, newest first. + // The `max` newest conversations, DM / room or channel, newest first. int recentChats(QRow* out, int max) { MessageHistory& h = _task->core().history; - struct Cand { bool is_ch; uint8_t ch; uint8_t prefix[4]; uint32_t ts; } c[4]; + struct Cand { bool is_ch; uint8_t ch; uint8_t prefix[4]; uint32_t ts; } c[2 * QUICK_MAX]; + if (max > QUICK_MAX) max = QUICK_MAX; int nc = 0; - for (int j = 0; j < h.dmHistCount() && nc < 2; j++) { + for (int j = 0; j < h.dmHistCount() && nc < max; j++) { const DmHistEntry& e = h.dmAtPos(h.dmHistPosNewest(j)); bool seen = false; for (int k = 0; k < nc; k++) if (memcmp(c[k].prefix, e.prefix, 4) == 0) seen = true; @@ -432,7 +458,7 @@ class HomeScreen : public UIScreen { 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++) { + for (int j = 0; j < h.chHistCount() && nc < nd + max; j++) { const ChHistEntry& e = h.chAtPos(h.chHistPosNewest(j)); bool seen = false; for (int k = nd; k < nc; k++) if (c[k].ch == e.ch_idx) seen = true; @@ -487,7 +513,7 @@ class HomeScreen : public UIScreen { #endif add("All settings", Q_MORE, QA_MORE, 0); } else if (page == QUICK_MSG) { - n = recentChats(rows, 2); + n = recentChats(rows, quickFit() - 1); add("All messages", Q_MORE, QA_MORE, 0); } else if (page == BLUETOOTH) { const bool on = _task->isSerialEnabled(); @@ -713,7 +739,7 @@ class HomeScreen : public UIScreen { const int cw = display.getCharWidth(); const int ind = cw + 2; // single-char indicator width const int ind_h = display.isSingleFont() ? lh - 2 : lh; - const int ind_gap = display.isLandscape() ? 3 : 1; // gap between indicator boxes + const int ind_gap = display.pixelScale() > 1 ? 3 : 1; // gap between indicator boxes int battLeftX; if (mode == battery::PERCENT) { @@ -731,7 +757,7 @@ class HomeScreen : public UIScreen { } else { // icon — scales with lh, same box height as the status icons beside it (ind_h) const int iconH = ind_h; const int iconW = lh * 2; - const int bm = display.isLandscape() ? 3 : 2; // inner margin: 3px on landscape e-ink, 2px on OLED/portrait + const int bm = display.pixelScale() > 1 ? 3 : 2; // inner margin: 3px on a doubled panel, else 2px battLeftX = display.width() - iconW - 3; display.drawRect(battLeftX, 0, iconW, iconH); // Nub height/2, vertically centred by remaining-space/2 rather than a flat @@ -969,10 +995,13 @@ public: // The Map page: the mini-map in a soft frame on the left, what it shows // spelled out in a column on the right (GPS, the trail, live contacts or // the target). Without a position the column still says what's missing. + // A tall screen (portrait e-ink) stacks them: the map, the four rows under it. void drawMapPage(DisplayDriver& d, int top) { const int W = d.width(), H = d.height(), lh = d.getLineHeight(); const int s = miniIconScale(d); - const int pw = W / 2, ph = H - top; // map panel + const bool tall = H - top > W; + const int rows_h = tall ? 4 * (lh + lh / 2) : 0; + const int pw = tall ? W : W / 2, ph = H - top - rows_h; // map panel d.setColor(DisplayDriver::LIGHT); d.drawSoftRect(0, top, pw, ph); if (!drawMapPreview(d, 2, top + 2, pw - 4, ph - 4)) { @@ -989,9 +1018,9 @@ public: } // 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; + const int cx = tall ? 2 : pw + 3, cw = W - cx; + const int row_h = tall ? rows_h / 4 : ph / 4; + int y = (tall ? top + ph : top) + (row_h - lh) / 2 + 1; auto line = [&](const MiniIcon& ic, const char* text) { miniIconDraw(d, cx, y, ic); const int tx = cx + ic.w * s + 2 * s; @@ -1051,6 +1080,25 @@ public: // ── Clock page: the big clock, a seconds bar under it, the date, and the // clock fields (Settings) as columns along the bottom, name over value. // Without fields the clock and date sit in the middle. + // The days left this month the alarm goes off on, as calendar::draw()'s + // marks: the repeat days, or a one-shot's next (today or tomorrow). + uint32_t alarmDays(const struct tm& t) const { + if (!_node_prefs || !_node_prefs->alarm_on) return 0; + const int n = calendar::daysIn(t.tm_year, t.tm_mon); + const uint8_t mask = _node_prefs->alarm_repeat_mask; + const bool passed = t.tm_hour * 60 + t.tm_min >= _node_prefs->alarm_hour * 60 + _node_prefs->alarm_min; + uint32_t days = 0; + for (int day = t.tm_mday; day <= n; day++) { + if (day == t.tm_mday && passed) continue; + const int wday = (t.tm_wday + day - t.tm_mday) % 7; + if (mask ? (mask & (1 << wday)) : true) { + days |= 1UL << (day - 1); + if (!mask) break; // one-shot: only the next + } + } + return days; + } + void drawClockPage(DisplayDriver& d, uint32_t unix_ts) { struct tm ti; localTm(unix_ts, _node_prefs ? _node_prefs->tz_offset_hours : 0, ti); @@ -1066,7 +1114,7 @@ public: int date_y; if (d.height() > d.width()) { // portrait e-ink: stacked digits - date_y = drawClockTall(d, 0, &ti, h12); + date_y = drawClockTall(d, lh / 2, &ti, h12); } else { const BigClock bc = bigClockSize(d); const int bar = 3 * bc.sc / 2; // the seconds bar and its gaps @@ -1088,6 +1136,12 @@ 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 (nf == 0) return; refresh_sensors(); const int val_y = H - lh, lab_y = val_y - lh - 1, cw = W / nf; @@ -1173,8 +1227,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 (!hint && unread > 0) snprintf(pill, sizeof(pill), "%d%s new", unread, _task->getAnyUnreadOverflow() ? "+" : ""); + if (!hint && unread > 0 && !(synced && H > W)) snprintf(pill, sizeof(pill), "%d%s new", unread, _task->getAnyUnreadOverflow() ? "+" : ""); } if (hint) { @@ -1194,6 +1249,40 @@ public: d.setColor(DisplayDriver::LIGHT); } + // Under a tall lock clock (portrait e-ink), between y and bottom: the month, + // then a row each for unread messages, the armed alarm and the clock fields + // -- the rows first, the calendar only if there's room for it as well. + void drawLockTallExtras(DisplayDriver& d, int y, int bottom, const struct tm& ti, int unread) { + const int W = d.width(), m = W / 16, step = d.lineStep(), s = miniIconScale(d); + struct Row { const MiniIcon* ic; const char* label; char value[20]; } rows[6]; + int n = 0; + auto add = [&](const MiniIcon* ic, const char* label) -> char* { + rows[n].ic = ic; rows[n].label = label; rows[n].value[0] = 0; + return rows[n++].value; + }; + if (unread > 0) + snprintf(add(&ICON_PG_MSG, "Messages"), sizeof(rows[0].value), "%d%s new", unread, _task->getAnyUnreadOverflow() ? "+" : ""); + if (_node_prefs && _node_prefs->alarm_on) + snprintf(add(&ICON_ALARM, "Alarm"), sizeof(rows[0].value), "%02d:%02d", _node_prefs->alarm_hour, _node_prefs->alarm_min); + uint8_t fields[3]; + const int nf = clockFields(fields); + if (nf) refresh_sensors(); + for (int k = 0; k < nf && n < 6; k++) { + if (fields[k] == telemetry::MSGS && unread > 0) continue; // already a row + clockFieldValue(fields[k], add(nullptr, telemetry::LABEL[fields[k]]), sizeof(rows[0].value)); + } + const int rows_h = n * step; + if (calendar::height(d, ti) + d.getLineHeight() / 2 + rows_h <= bottom - y) { + calendar::draw(d, 0, y, W, ti, alarmDays(ti)); + y += calendar::height(d, ti) + d.getLineHeight() / 2; + } + for (int k = 0; k < n && y + d.getLineHeight() <= bottom; k++, y += step) { + int x = m; + if (rows[k].ic) { miniIconDraw(d, x, y, *rows[k].ic); x += rows[k].ic->w * s + 2 * s; } + info::valueRow(d, y, rows[k].label, rows[k].value, false, m - 2, x); + } + } + // The Status page: four tiles split by dotted rules, each one subject's // headline (icon + main value) over a detail line. Enter opens the Status // screen with all of it. @@ -1286,8 +1375,11 @@ public: // gap scale with the font so the band clears the header above and content // below (identical to the old lh+4 / +6 dots layout at 1x). const int pg_half = (5 * miniIconScale(display) + 1) / 2; - const int dots_y = lh + pg_half + 1; // icon-row centre, below the header - const int content_y = dots_y + pg_half + 3; // first content row, below the icons (= contentTop()) + const bool one_row = stripOnHeader(display); // the icons on the header line + const int dots_y = one_row ? lh / 2 : lh + pg_half + 1; // icon-row centre + const int content_y = contentTop(display); // first content row, below the icons + const int strip_l = display.width() / 2 - stripHalfW(display); + const int strip_r = display.width() / 2 + stripHalfW(display); const float slide = slideProgress(); // -1, or how far a page turn has got // Title bar displaying node name (except on lock screen), status icons and battery. @@ -1296,8 +1388,9 @@ public: display.setColor(DisplayDriver::LIGHT); // The lock page has nothing on the left of the bar (its clock sits // below it), so the status icons may use the whole row. - const int lock_reserve = _page == LOCK ? 0 : -1; + const int lock_reserve = _page == LOCK ? 0 : one_row ? strip_r + 2 : -1; int rightEdge = renderBatteryIndicator(display, _task->getBattMilliVolts(), lock_reserve); + if (one_row && _page != LOCK) rightEdge = strip_l; // the time stops at the icons display.setColor(DisplayDriver::LIGHT); if (_page != LOCK) { @@ -1391,7 +1484,7 @@ public: } else if (isQuickPage(_page) && !(_page == HomePage::SHUTDOWN && _shutdown_init)) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); - QRow rows[3]; + QRow rows[QUICK_MAX]; const int n = buildQuick(_page, rows); int q = drawQuickPanel(display, content_y, rows, n); if (_page == HomePage::TOOLS && _task->isTimerRunning()) q = 1000; // the timer's countdown @@ -1439,6 +1532,8 @@ public: uint8_t unread = 0; bool overflow = false; bool resolved = false; + uint32_t last_ts = 0; // heard from / last post, for a tall card + int32_t plat = 0, plon = 0; // a contact's advertised position if (prefix && _task->favouriteSlotKind(i) == NodePrefs::FAV_KIND_CHANNEL) { uint8_t ch_idx = prefix[0]; @@ -1451,6 +1546,11 @@ public: unread = _task->getChannelUnread(ch_idx); overflow = unread > 0 && _task->getChannelUnreadOverflow(ch_idx); resolved = true; + MessageHistory& h = _task->core().history; + for (int j = 0; j < h.chHistCount(); j++) { + const ChHistEntry& e = h.chAtPos(h.chHistPosNewest(j)); + if (e.ch_idx == ch_idx) { last_ts = e.timestamp; break; } + } } } else if (prefix) { for (int idx = 0; ; idx++) { @@ -1461,6 +1561,8 @@ public: unread = _task->getDMUnread(c.id.pub_key); overflow = unread > 0 && _task->getDMUnreadOverflow(c.id.pub_key); resolved = true; + last_ts = c.lastmod; + plat = c.gps_lat; plon = c.gps_lon; break; } } @@ -1479,13 +1581,41 @@ 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 = ty + (th - line_h) / 2; + // A tall card (e-ink) has the name at the top and lines of detail + // under it: when it was last heard (a channel: its last post) and, + // for a contact with a position, how far away it is. + const bool tall = th >= 3 * line_h + 6; + int name_y = tall ? ty + 3 : ty + (th - line_h) / 2; int name_max_w = tw - 6 - bw; if (name_max_w < 6) name_max_w = 6; int r = display.drawTextEllipsized(tx + 3, name_y, name_max_w, name, sel); if (sel && r > 0) mq_delay = r; if (unread > 0) display.drawUnreadBadge(tx + tw - 3, name_y, unread, sel, overflow); + if (tall) { + display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); // the badge leaves its own + const int s = miniIconScale(display), gap = line_h / 3; + int y = name_y + line_h + gap; + auto detail = [&](const MiniIcon& ic, const char* text) { + if (y + line_h > ty + th - 2) return; + miniIconDraw(display, tx + 3, y, ic); + const int x = tx + 3 + ic.w * s + 2 * s; + display.drawTextEllipsized(x, y, tx + tw - 3 - x, text); + y += line_h + gap; + }; + char buf[16]; + const uint32_t now = rtc_clock.getCurrentTime(); + if (last_ts) { + geo::fmtAgeShort(buf, sizeof(buf), now, last_ts); + detail(prefix && _task->favouriteSlotKind(i) == NodePrefs::FAV_KIND_CHANNEL ? ICON_PG_MSG : ICON_PG_CLOCK, buf); + } + int32_t mla, mlo; + if ((plat || plon) && _task->currentLocation(mla, mlo)) { + // The distance after an arrow towards it (north up). + geo::fmtDist(buf, sizeof(buf), geo::haversineKm(mla, mlo, plat, plon), _task->useImperial(), true); + detail(*ICON_ARROWS[((geo::bearingDeg(mla, mlo, plat, plon) + 22) % 360) / 45], buf); + } + } } else { display.drawTextCentered(tx + tw / 2, ty + (th - line_h) / 2, "+"); } @@ -1588,7 +1718,7 @@ public: 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]; + QRow rows[QUICK_MAX]; const int n = buildQuick(_page, rows); const QAct act = n > 0 ? rows[_quick_sel < n ? _quick_sel : n - 1].act : QA_NONE; const int d = c == KEY_RIGHT ? 1 : -1; @@ -1615,7 +1745,7 @@ public: return true; // nothing else while editing } if (isQuickPage(_page)) { - QRow rows[3]; + QRow rows[QUICK_MAX]; const int n = buildQuick(_page, rows); if (c == KEY_UP) { if (_quick_sel > 0) _quick_sel--; return true; } if (c == KEY_DOWN) { if (_quick_sel + 1 < n) _quick_sel++; return true; } diff --git a/examples/companion_radio/ui-new/icons.h b/examples/companion_radio/ui-new/icons.h index 5192ce20..6c22b9e0 100644 --- a/examples/companion_radio/ui-new/icons.h +++ b/examples/companion_radio/ui-new/icons.h @@ -386,6 +386,75 @@ MINI_ICON(ICON_MAP_TARGET, 5, // ⚑ flag on a pole — the active Locator/N packRow("#...."), packRow("#....")); +// Arrows towards the eight compass points (north up), for a bearing beside +// a distance: ICON_ARROWS[((deg + 22) % 360) / 45]. +MINI_ICON(ICON_ARROW_N, 7, + packRow("...#..."), + packRow("..###.."), + packRow(".#.#.#."), + packRow("...#..."), + packRow("...#..."), + packRow("...#..."), + packRow("...#...")); +MINI_ICON(ICON_ARROW_NE, 7, + packRow("...####"), + packRow(".....##"), + packRow("....#.#"), + packRow("...#..#"), + packRow("..#...."), + packRow(".#....."), + packRow("#......")); +MINI_ICON(ICON_ARROW_E, 7, + packRow("......."), + packRow("....#.."), + packRow(".....#."), + packRow("#######"), + packRow(".....#."), + packRow("....#.."), + packRow(".......")); +MINI_ICON(ICON_ARROW_SE, 7, + packRow("#......"), + packRow(".#....."), + packRow("..#...."), + packRow("...#..#"), + packRow("....#.#"), + packRow(".....##"), + packRow("...####")); +MINI_ICON(ICON_ARROW_S, 7, + packRow("...#..."), + packRow("...#..."), + packRow("...#..."), + packRow("...#..."), + packRow(".#.#.#."), + packRow("..###.."), + packRow("...#...")); +MINI_ICON(ICON_ARROW_SW, 7, + packRow("......#"), + packRow(".....#."), + packRow("....#.."), + packRow("#..#..."), + packRow("#.#...."), + packRow("##....."), + packRow("####...")); +MINI_ICON(ICON_ARROW_W, 7, + packRow("......."), + packRow("..#...."), + packRow(".#....."), + packRow("#######"), + packRow(".#....."), + packRow("..#...."), + packRow(".......")); +MINI_ICON(ICON_ARROW_NW, 7, + packRow("####..."), + packRow("##....."), + packRow("#.#...."), + packRow("#..#..."), + packRow("....#.."), + packRow(".....#."), + packRow("......#")); +static constexpr const MiniIcon* ICON_ARROWS[8] = { &ICON_ARROW_N, &ICON_ARROW_NE, &ICON_ARROW_E, &ICON_ARROW_SE, + &ICON_ARROW_S, &ICON_ARROW_SW, &ICON_ARROW_W, &ICON_ARROW_NW }; + // Keyboard special-key glyphs. MINI_ICON(ICON_KEYBOARD, 7, // PIN keyboard to ABC keyboard switch icon packRow("#.#.#.#"),