diff --git a/examples/companion_radio/ui-core/Lettering.h b/examples/companion_radio/ui-core/Lettering.h new file mode 100644 index 00000000..d295b787 --- /dev/null +++ b/examples/companion_radio/ui-core/Lettering.h @@ -0,0 +1,138 @@ +#pragma once +// The MeshCore wordmark and its lettering, shared by both boot splashes +// (ui-new/UITask.cpp on the OLED/e-ink boards, ui-lvgl/Splash.h on the L2): +// the 128x13 wordmark bitmap, its own letters, the extra characters drawn in +// the same strokes (digits, '.', '-', v, l, d, space), and the Solo version / +// build date strings both splashes show. Platform-free: callers plot pixels. + +#include +#include +#include +#include + +namespace lettering { +static const int LOGO_W = 128, LOGO_H = 13; + +// 'meshcore', 128x13 px, MSB first +static const uint8_t LOGO[] = { + 0x3c, 0x01, 0xe3, 0xff, 0xc7, 0xff, 0x8f, 0x03, 0x87, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, + 0x3c, 0x03, 0xe3, 0xff, 0xc7, 0xff, 0x8e, 0x03, 0x8f, 0xfe, 0x3f, 0xfe, 0x1f, 0xff, 0x1f, 0xfe, + 0x3e, 0x03, 0xc3, 0xff, 0x8f, 0xff, 0x0e, 0x07, 0x8f, 0xfe, 0x7f, 0xfe, 0x1f, 0xff, 0x1f, 0xfc, + 0x3e, 0x07, 0xc7, 0x80, 0x0e, 0x00, 0x0e, 0x07, 0x9e, 0x00, 0x78, 0x0e, 0x3c, 0x0f, 0x1c, 0x00, + 0x3e, 0x0f, 0xc7, 0x80, 0x1e, 0x00, 0x0e, 0x07, 0x1e, 0x00, 0x70, 0x0e, 0x38, 0x0f, 0x3c, 0x00, + 0x7f, 0x0f, 0xc7, 0xfe, 0x1f, 0xfc, 0x1f, 0xff, 0x1c, 0x00, 0x70, 0x0e, 0x38, 0x0e, 0x3f, 0xf8, + 0x7f, 0x1f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x0e, 0x38, 0x0e, 0x3f, 0xf8, + 0x7f, 0x3f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x1e, 0x3f, 0xfe, 0x3f, 0xf0, + 0x77, 0x3b, 0x87, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xfc, 0x38, 0x00, + 0x77, 0xfb, 0x8f, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xf8, 0x38, 0x00, + 0x73, 0xf3, 0x8f, 0xff, 0x0f, 0xff, 0x1c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x78, 0x7f, 0xf8, + 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfe, 0x3c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x3c, 0x7f, 0xf8, + 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8, +}; + +// The wordmark's own letters: columns of LOGO. +struct LogoLetter { char c; uint8_t x0, w; }; +static const LogoLetter LOGO_LETTERS[] = { + { 'm', 0, 18 }, { 'e', 19, 15 }, { 's', 35, 15 }, { 'h', 50, 16 }, { 'c', 66, 14 }, { 'o', 80, 16 }, { 'r', 96, 16 }, +}; +// The rest, drawn upright in the same strokes (3 px bars at the top, middle +// and bottom); slanted like the wordmark when drawn (SLANT). +struct PixGlyph { char c; const char* rows[LOGO_H]; }; +#define F10 "##########" +#define L10 "###......." +#define R10 ".......###" +#define B10 "###....###" +static const PixGlyph GLYPHS[] = { + { '0', { F10, F10, F10, B10, B10, B10, B10, B10, B10, B10, F10, F10, F10 } }, + { '1', { "....####..", "...#####..", "..######..", "....####..", "....####..", "....####..", "....####..", + "....####..", "....####..", "....####..", "....####..", "....####..", "....####.." } }, + { '2', { F10, F10, F10, R10, R10, F10, F10, F10, L10, L10, F10, F10, F10 } }, + { '3', { F10, F10, F10, R10, R10, F10, F10, F10, R10, R10, F10, F10, F10 } }, + { '4', { B10, B10, B10, B10, B10, F10, F10, F10, R10, R10, R10, R10, R10 } }, + { '5', { F10, F10, F10, L10, L10, F10, F10, F10, R10, R10, F10, F10, F10 } }, + { '6', { F10, F10, F10, L10, L10, F10, F10, F10, B10, B10, F10, F10, F10 } }, + { '7', { F10, F10, F10, R10, R10, R10, R10, R10, R10, R10, R10, R10, R10 } }, + { '8', { F10, F10, F10, B10, B10, F10, F10, F10, B10, B10, F10, F10, F10 } }, + { '9', { F10, F10, F10, B10, B10, F10, F10, F10, R10, R10, F10, F10, F10 } }, + { '.', { "....", "....", "....", "....", "....", "....", "....", "....", "....", "....", "###.", "###.", "###." } }, + { '-', { ".......", ".......", ".......", ".......", ".......", "#######", "#######", "#######", + ".......", ".......", ".......", ".......", "......." } }, + { 'v', { "###.....###", "###.....###", "###.....###", "###.....###", "###.....###", "###.....###", "###.....###", + "###.....###", ".###...###.", ".###...###.", "..###.###..", "...#####...", "....###...." } }, + { 'l', { "###......", "###......", "###......", "###......", "###......", "###......", "###......", + "###......", "###......", "###......", "#########", "#########", "#########" } }, + { 'd', { "#########.", F10, F10, B10, B10, B10, B10, B10, B10, B10, F10, F10, "#########." } }, + { ' ', { ".....", ".....", ".....", ".....", ".....", ".....", ".....", ".....", ".....", ".....", ".....", ".....", "....." } }, +}; +#undef F10 +#undef L10 +#undef R10 +#undef B10 +static const uint8_t SLANT[LOGO_H] = { 2, 2, 2, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0 }; // px right, per row + +static inline bool logoBit(int x, int y) { + return LOGO[y * (LOGO_W / 8) + x / 8] & (0x80 >> (x % 8)); +} + +// One character of the lettering: its width, and whether (x, y) is inked. +static inline const LogoLetter* logoLetter(char c) { + for (const LogoLetter& l : LOGO_LETTERS) if (l.c == c) return &l; + return nullptr; +} +static inline const PixGlyph* pixGlyph(char c) { + for (const PixGlyph& g : GLYPHS) if (g.c == c) return &g; + return nullptr; +} +static inline int charW(char c) { + if (const LogoLetter* l = logoLetter(c)) return l->w; + if (const PixGlyph* g = pixGlyph(c)) return (int)strlen(g->rows[0]) + 2; // + the slant + return -1; +} +static inline bool inked(char c, int x, int y) { + if (const LogoLetter* l = logoLetter(c)) { + return logoBit(l->x0 + x, y); + } + const PixGlyph* g = pixGlyph(c); + int gx = x - SLANT[y]; + return g && gx >= 0 && gx < (int)strlen(g->rows[y]) && g->rows[y][gx] == '#'; +} + +// `text` in the lettering (lower case), -1 if it has a character that isn't drawn. +static inline int textW(const char* text, int gap) { + int w = 0; + for (const char* p = text; *p; p++) { + int cw = charW((char)tolower((unsigned char)*p)); + if (cw < 0) return -1; + w += cw + (p[1] ? gap : 0); + } + return w; +} + +// "v1.28-solo.1-abcdef" -> "v1.28-solo.1": build.sh appends the commit as +// the last dash segment . +static inline void soloVersion(char* out, size_t n) { + const char* ver = FIRMWARE_VERSION; + const char* dash = strrchr(ver, '-'); + size_t len = dash ? (size_t)(dash - ver) : strlen(ver); + if (len >= n) len = n - 1; + memcpy(out, ver, len); + out[len] = '\0'; +} + +// The build date as digits, "2026-09-26": build.sh gives "26-Sep-2026", +// __DATE__ "Sep 26 2026". Anything else is passed through. +static inline void buildDate(char* out, size_t n) { + static const char MON[] = "JanFebMarAprMayJunJulAugSepOctNovDec"; + const char* d = FIRMWARE_BUILD_DATE; + int day = 0, year = 0, mon = 0; + char m[4] = ""; + if (sscanf(d, "%d-%3s-%d", &day, m, &year) != 3 && sscanf(d, "%3s %d %d", m, &day, &year) != 3) { + snprintf(out, n, "%s", d); + return; + } + const char* f = strstr(MON, m); + mon = f && m[0] ? (int)(f - MON) / 3 + 1 : 0; + if (mon) snprintf(out, n, "%04d-%02d-%02d", year, mon, day); + else snprintf(out, n, "%s", d); +} +} // namespace lettering diff --git a/examples/companion_radio/ui-lvgl/Splash.h b/examples/companion_radio/ui-lvgl/Splash.h index 6707c840..f9440af2 100644 --- a/examples/companion_radio/ui-lvgl/Splash.h +++ b/examples/companion_radio/ui-lvgl/Splash.h @@ -8,66 +8,12 @@ // (status bar too) from lv_layer_top, fades out after SPLASH_MS; a tap skips // it. Home -- and the PIN lock, if set -- are already built underneath. +#include "../ui-core/Lettering.h" + namespace splash { static const uint32_t SPLASH_MS = 2500; -static const int LOGO_W = 128, LOGO_H = 13, LOGO_SCALE = 2; - -// 'meshcore', 128x13 px, MSB first (ui-new/icons.h meshcore_logo) -static const uint8_t LOGO[] = { - 0x3c, 0x01, 0xe3, 0xff, 0xc7, 0xff, 0x8f, 0x03, 0x87, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, - 0x3c, 0x03, 0xe3, 0xff, 0xc7, 0xff, 0x8e, 0x03, 0x8f, 0xfe, 0x3f, 0xfe, 0x1f, 0xff, 0x1f, 0xfe, - 0x3e, 0x03, 0xc3, 0xff, 0x8f, 0xff, 0x0e, 0x07, 0x8f, 0xfe, 0x7f, 0xfe, 0x1f, 0xff, 0x1f, 0xfc, - 0x3e, 0x07, 0xc7, 0x80, 0x0e, 0x00, 0x0e, 0x07, 0x9e, 0x00, 0x78, 0x0e, 0x3c, 0x0f, 0x1c, 0x00, - 0x3e, 0x0f, 0xc7, 0x80, 0x1e, 0x00, 0x0e, 0x07, 0x1e, 0x00, 0x70, 0x0e, 0x38, 0x0f, 0x3c, 0x00, - 0x7f, 0x0f, 0xc7, 0xfe, 0x1f, 0xfc, 0x1f, 0xff, 0x1c, 0x00, 0x70, 0x0e, 0x38, 0x0e, 0x3f, 0xf8, - 0x7f, 0x1f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x0e, 0x38, 0x0e, 0x3f, 0xf8, - 0x7f, 0x3f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x1e, 0x3f, 0xfe, 0x3f, 0xf0, - 0x77, 0x3b, 0x87, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xfc, 0x38, 0x00, - 0x77, 0xfb, 0x8f, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xf8, 0x38, 0x00, - 0x73, 0xf3, 0x8f, 0xff, 0x0f, 0xff, 0x1c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x78, 0x7f, 0xf8, - 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfe, 0x3c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x3c, 0x7f, 0xf8, - 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8, -}; - -// The wordmark's own letters: columns of LOGO. -struct LogoLetter { char c; uint8_t x0, w; }; -static const LogoLetter LOGO_LETTERS[] = { - { 'm', 0, 18 }, { 'e', 19, 15 }, { 's', 35, 15 }, { 'h', 50, 16 }, { 'c', 66, 14 }, { 'o', 80, 16 }, { 'r', 96, 16 }, -}; -// The rest, drawn upright in the same strokes (3 px bars at the top, middle -// and bottom); slanted like the wordmark when drawn (SLANT). -struct PixGlyph { char c; const char* rows[LOGO_H]; }; -#define F10 "##########" -#define L10 "###......." -#define R10 ".......###" -#define B10 "###....###" -static const PixGlyph GLYPHS[] = { - { '0', { F10, F10, F10, B10, B10, B10, B10, B10, B10, B10, F10, F10, F10 } }, - { '1', { "....####..", "...#####..", "..######..", "....####..", "....####..", "....####..", "....####..", - "....####..", "....####..", "....####..", "....####..", "....####..", "....####.." } }, - { '2', { F10, F10, F10, R10, R10, F10, F10, F10, L10, L10, F10, F10, F10 } }, - { '3', { F10, F10, F10, R10, R10, F10, F10, F10, R10, R10, F10, F10, F10 } }, - { '4', { B10, B10, B10, B10, B10, F10, F10, F10, R10, R10, R10, R10, R10 } }, - { '5', { F10, F10, F10, L10, L10, F10, F10, F10, R10, R10, F10, F10, F10 } }, - { '6', { F10, F10, F10, L10, L10, F10, F10, F10, B10, B10, F10, F10, F10 } }, - { '7', { F10, F10, F10, R10, R10, R10, R10, R10, R10, R10, R10, R10, R10 } }, - { '8', { F10, F10, F10, B10, B10, F10, F10, F10, B10, B10, F10, F10, F10 } }, - { '9', { F10, F10, F10, B10, B10, F10, F10, F10, R10, R10, F10, F10, F10 } }, - { '.', { "....", "....", "....", "....", "....", "....", "....", "....", "....", "....", "###.", "###.", "###." } }, - { '-', { ".......", ".......", ".......", ".......", ".......", "#######", "#######", "#######", - ".......", ".......", ".......", ".......", "......." } }, - { 'v', { "###.....###", "###.....###", "###.....###", "###.....###", "###.....###", "###.....###", "###.....###", - "###.....###", ".###...###.", ".###...###.", "..###.###..", "...#####...", "....###...." } }, - { 'l', { "###......", "###......", "###......", "###......", "###......", "###......", "###......", - "###......", "###......", "###......", "#########", "#########", "#########" } }, - { 'd', { "#########.", F10, F10, B10, B10, B10, B10, B10, B10, B10, F10, F10, "#########." } }, - { ' ', { ".....", ".....", ".....", ".....", ".....", ".....", ".....", ".....", ".....", ".....", ".....", ".....", "....." } }, -}; -#undef F10 -#undef L10 -#undef R10 -#undef B10 -static const uint8_t SLANT[LOGO_H] = { 2, 2, 2, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0 }; // px right, per row +using namespace lettering; // ../ui-core/Lettering.h: wordmark, letters, version/date +static const int LOGO_SCALE = 2; static lv_obj_t* s_root = nullptr; static const int MAX_IMGS = 6; @@ -95,41 +41,6 @@ static void onTimer(lv_timer_t* t) { (void)t; dismiss(); } static bool up() { return s_root != nullptr; } // until its fade-out has finished static void onTap(lv_event_t* e) { (void)e; dismiss(); } -// One character of the lettering: its width, and whether (x, y) is inked. -static const LogoLetter* logoLetter(char c) { - for (const LogoLetter& l : LOGO_LETTERS) if (l.c == c) return &l; - return nullptr; -} -static const PixGlyph* pixGlyph(char c) { - for (const PixGlyph& g : GLYPHS) if (g.c == c) return &g; - return nullptr; -} -static int charW(char c) { - if (const LogoLetter* l = logoLetter(c)) return l->w; - if (const PixGlyph* g = pixGlyph(c)) return (int)strlen(g->rows[0]) + 2; // + the slant - return -1; -} -static bool inked(char c, int x, int y) { - if (const LogoLetter* l = logoLetter(c)) { - int sx = l->x0 + x; - return LOGO[y * (LOGO_W / 8) + sx / 8] & (0x80 >> (sx % 8)); - } - const PixGlyph* g = pixGlyph(c); - int gx = x - SLANT[y]; - return g && gx >= 0 && gx < (int)strlen(g->rows[y]) && g->rows[y][gx] == '#'; -} - -// `text` in the lettering (lower case), -1 if it has a character that isn't drawn. -static int textW(const char* text, int gap) { - int w = 0; - for (const char* p = text; *p; p++) { - int cw = charW((char)tolower((unsigned char)*p)); - if (cw < 0) return -1; - w += cw + (p[1] ? gap : 0); - } - return w; -} - // A line in the lettering, `scale` x, in `col`: an image, or a Noto label when // the text has a character the lettering lacks. `text` null = the wordmark. static lv_obj_t* letters(lv_obj_t* parent, const char* text, int scale, int gap, uint32_t col, const lv_font_t* fallback) { @@ -147,7 +58,7 @@ static lv_obj_t* letters(lv_obj_t* parent, const char* text, int scale, int gap, if (!text) { for (int y = 0; y < LOGO_H; y++) for (int x = 0; x < LOGO_W; x++) - if (LOGO[y * (LOGO_W / 8) + x / 8] & (0x80 >> (x % 8))) plot(x, y); + if (logoBit(x, y)) plot(x, y); } else { int ox = 0; for (const char* p = text; *p; p++) { @@ -191,33 +102,6 @@ static void wave(lv_obj_t* dot, int i) { lv_anim_start(&a); } -// "v1.28-solo.1-abcdef" -> "v1.28-solo.1": build.sh appends the commit as -// the last dash segment (as ui-new's splash). -static void soloVersion(char* out, size_t n) { - const char* ver = FIRMWARE_VERSION; - const char* dash = strrchr(ver, '-'); - size_t len = dash ? (size_t)(dash - ver) : strlen(ver); - if (len >= n) len = n - 1; - memcpy(out, ver, len); - out[len] = '\0'; -} - -// The build date as digits, "2026-09-26": build.sh gives "26-Sep-2026", -// __DATE__ "Sep 26 2026". Anything else is passed through. -static void buildDate(char* out, size_t n) { - static const char MON[] = "JanFebMarAprMayJunJulAugSepOctNovDec"; - const char* d = FIRMWARE_BUILD_DATE; - int day = 0, year = 0, mon = 0; - char m[4] = ""; - if (sscanf(d, "%d-%3s-%d", &day, m, &year) != 3 && sscanf(d, "%3s %d %d", m, &day, &year) != 3) { - snprintf(out, n, "%s", d); - return; - } - const char* f = strstr(MON, m); - mon = f && m[0] ? (int)(f - MON) / 3 + 1 : 0; - if (mon) snprintf(out, n, "%04d-%02d-%02d", year, mon, day); - else snprintf(out, n, "%s", d); -} } // namespace splash void UITask::showSplash() { diff --git a/examples/companion_radio/ui-new/AdminScreen.h b/examples/companion_radio/ui-new/AdminScreen.h index 1b588466..389bee2b 100644 --- a/examples/companion_radio/ui-new/AdminScreen.h +++ b/examples/companion_radio/ui-new/AdminScreen.h @@ -61,6 +61,7 @@ class AdminScreen : public UIScreen { void openLoginKb() { kb().begin("", 15); // admin password: same max length as room/repeater login + kb().prompt = "Password"; kb().clearPlaceholders(); // {loc}/{time} are for messages, not a password _login_kb = true; } @@ -69,6 +70,7 @@ class AdminScreen : public UIScreen { // {} picker to CLI command-name completion (Custom-command row only). void openValueKb(const char* initial, bool cli_autocomplete) { kb().begin(initial, 160); + kb().prompt = "Command"; kb().clearPlaceholders(); // a CLI command/value is literal, not a message if (cli_autocomplete) kb().setPlaceholderRefresh(refreshCmdPlaceholders, nullptr, "Commands:"); _kb_active = true; @@ -162,6 +164,13 @@ public: } } + // A waiting line centred under the header, with the loading dots below it. + int waiting(DisplayDriver& display, const char* what) { + int mid = (display.listStart() + display.height()) / 2; + display.drawTextCentered(display.width() / 2, mid - display.lineStep(), what); + return drawLoadingDots(display, display.width() / 2, mid + display.lineStep()); + } + int render(DisplayDriver& display) override { display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); @@ -170,9 +179,8 @@ public: AdminSession::State st = S().state(); if (st == AdminSession::LOGGING_IN || st == AdminSession::IDLE || st == AdminSession::NEED_PASSWORD) { display.drawCenteredHeader("ADMIN LOGIN"); - display.setCursor(2, display.listStart()); - if (st == AdminSession::LOGGING_IN) display.print("Logging in..."); - return 500; + if (st != AdminSession::LOGGING_IN) return 500; + return waiting(display, "Logging in"); } if (_showing_reply) return _reply_view.render(display, S().target().name, S().reply(), false, false); if (_kb_active) return kb().render(display); @@ -180,9 +188,7 @@ public: char title[24]; snprintf(title, sizeof(title), "%.23s", S().target().name); display.drawCenteredHeader(title); - display.setCursor(2, display.listStart()); - display.print(S().fetching() ? "Fetching..." : "Waiting for reply..."); - return 500; + return waiting(display, S().fetching() ? "Fetching" : "Waiting for reply"); } tabbar::draw(display, admin::TAB_LABELS, admin::TAB_COUNT, _tab); diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index 3d1135c8..fb7e7baa 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -205,6 +205,7 @@ public: if (char* txt = botcfg::text(_prefs, k, cap)) { _kb_row = _sel; _kb->begin(txt, cap - 1); + _kb->prompt = botcfg::isTrigger(k) ? "Trigger" : "Reply"; if (botcfg::isTrigger(k)) { _kb->clearPlaceholders(); // trigger is literal — placeholders never match } else { diff --git a/examples/companion_radio/ui-new/ChannelsView.h b/examples/companion_radio/ui-new/ChannelsView.h index e6c382bd..6131cecb 100644 --- a/examples/companion_radio/ui-new/ChannelsView.h +++ b/examples/companion_radio/ui-new/ChannelsView.h @@ -51,8 +51,9 @@ class ChannelsView { KeyboardWidget& kb() { return _task->keyboard(); } - void openKb(const char* initial, int max) { + void openKb(const char* initial, int max, const char* prompt) { kb().begin(initial, max); + kb().prompt = prompt; kb().clearPlaceholders(); // literal field — {loc}/{time} make no sense here _kb_active = true; } @@ -195,7 +196,7 @@ public: if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 1; return true; } if (c == KEY_DOWN) { _sel = (_sel < 1) ? _sel + 1 : 0; return true; } if (c == KEY_ENTER) { - if (_sel == 0) { _kb_field = 2; openKb(_topic, sizeof(_topic) - 1); } + if (_sel == 0) { _kb_field = 2; openKb(_topic, sizeof(_topic) - 1, "Hashtag"); } else commit(); } return true; @@ -210,8 +211,8 @@ public: return true; } if (c == KEY_ENTER) { - if (_sel == 0) { _kb_field = 0; openKb(_name, sizeof(_name) - 1); } - else if (_sel == 1) { _kb_field = 1; openKb(_secret_text, _hex_mode ? 32 : (int)sizeof(_secret_text) - 1); } + if (_sel == 0) { _kb_field = 0; openKb(_name, sizeof(_name) - 1, "Name"); } + else if (_sel == 1) { _kb_field = 1; openKb(_secret_text, _hex_mode ? 32 : (int)sizeof(_secret_text) - 1, "Secret"); } else commit(); return true; } diff --git a/examples/companion_radio/ui-new/ClockToolsScreen.h b/examples/companion_radio/ui-new/ClockToolsScreen.h index 4c65729c..d3329d6f 100644 --- a/examples/companion_radio/ui-new/ClockToolsScreen.h +++ b/examples/companion_radio/ui-new/ClockToolsScreen.h @@ -131,7 +131,7 @@ class ClockToolsScreen : public UIScreen { int cx = valx + k * cw; if (k == char_idx) { d.setColor(DisplayDriver::DARK); - d.fillRect(cx, y - 1, cw, d.getLineHeight() + 1); + d.fillSoftRect(cx, y - 1, cw, d.getLineHeight() + 1); d.setColor(DisplayDriver::LIGHT); } else { d.setColor(DisplayDriver::DARK); diff --git a/examples/companion_radio/ui-new/DigitEditor.h b/examples/companion_radio/ui-new/DigitEditor.h index b5eb9599..0179d45d 100644 --- a/examples/companion_radio/ui-new/DigitEditor.h +++ b/examples/companion_radio/ui-new/DigitEditor.h @@ -77,7 +77,7 @@ struct DigitEditor { int cx = x + k * cw; if (k == char_idx) { d.setColor(DisplayDriver::DARK); - d.fillRect(cx, y - 1, cw, d.getLineHeight() + 1); + d.fillSoftRect(cx, y - 1, cw, d.getLineHeight() + 1); d.setColor(DisplayDriver::LIGHT); } else { d.setColor(DisplayDriver::DARK); diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index 1db8112e..af89b306 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -271,7 +271,10 @@ struct KeyboardWidget { // The number pad (beginPin) instead of the user's ABC / T9 layout; its ABC // key drops back to that layout for a password with letters. bool pin_mode = false; - const char* pin_prompt = nullptr; // e.g. "New PIN", shown in the preview; kept past the ABC switch + // What is being typed ("New PIN", "Password", a channel name...), shown at + // the right of the preview; set it after begin(), which clears it. Kept past + // the PIN pad's ABC switch. + const char* prompt = nullptr; static const int PIN_MAX_LEN = 16; int row, col; int page; // see totalPages()/scriptAt()/pageIsSymbols() below @@ -416,7 +419,7 @@ struct KeyboardWidget { caps_lock = false; pin_kb_mask_enabled = false; pin_mode = false; - pin_prompt = nullptr; + prompt = nullptr; t9_cell = -1; t9_cycle = 0; _ph_menu.active = false; @@ -430,10 +433,10 @@ struct KeyboardWidget { } // Open keyboard in PIN mode - void beginPin(const char* initial = "", int max = PIN_MAX_LEN, bool mask = false, const char* prompt = nullptr) { + void beginPin(const char* initial = "", int max = PIN_MAX_LEN, bool mask = false, const char* label = nullptr) { begin(initial, max); pin_mode = true; - pin_prompt = prompt; + prompt = label; pin_kb_mask_enabled = mask; // Mask input of number field } @@ -518,6 +521,29 @@ struct KeyboardWidget { else if (key == KEY_DOWN) { cursor_pos = len; } } + // PIN preview: what it's for, centred, and the digits below it spaced out + // (filled soft squares when masked), with an underline on the next slot. + void renderPinPreview(DisplayDriver& display, int sep_y) { + const int lh = display.getLineHeight(), cw = display.getCharWidth(); + const int pitch = cw + 3; + const int y = sep_y - lh; // digits on the last preview line + int title_y = y - lh - 1; + if (title_y < 0) title_y = 0; + if (prompt && title_y + lh <= y) display.drawTextCentered(display.width() / 2, title_y, prompt); + const int slots = len + (len < max_len ? 1 : 0); + int x = display.width() / 2 - (slots * pitch - 3) / 2; + for (int i = 0; i < slots; i++, x += pitch) { + if (i == len) { display.fillRect(x, y + lh - 2, cw, 1); continue; } // next digit goes here + if (pin_kb_mask_enabled) { + display.fillSoftRect(x, y + 1, cw, lh - 3); + } else { + char d[2] = { buf[i], 0 }; + display.setCursor(x, y); + display.print(d); + } + } + } + int render(DisplayDriver& display) { _visible = true; // A stale mid-cycle T9 press (no further input since) finalizes on its own — @@ -576,6 +602,9 @@ struct KeyboardWidget { // ...and the byte offset that line starts at. int ps = 0; for (int n = first_line * cpl; n > 0 && ps < len; n--) ps += kbUtf8CharBytesAt(buf, ps, len); + if (isPin()) { + renderPinPreview(display, sep_y); + } else for (int pl = 0; pl < prev_lines; pl++) { int pe = ps; // byte offset cpl codepoints further along (or end of text) for (int k = 0; k < cpl && pe < len; k++) pe += kbUtf8CharBytesAt(buf, pe, len); @@ -622,9 +651,27 @@ struct KeyboardWidget { display.print(linebuf_t); ps = pe; } - if (pin_prompt) { // what the PIN is for, bottom-right of the preview (alerts don't show over the keyboard) - display.setCursor(display.width() - display.getTextWidth(pin_prompt), (prev_lines - 1) * lh); - display.print(pin_prompt); + if (!isPin()) { + // Bottom-right of the preview: what the field is, or near the limit how + // much room is left. Only when it clears the text on that line. + char left[12]; + const char* tag = prompt; + if (max_len > 0 && len * 4 >= max_len * 3) { snprintf(left, sizeof(left), "%d left", max_len - len); tag = left; } + if (tag) { + int tw = display.getTextWidth(tag); + // Codepoints on the last preview line, plus the cursor if it's there. + int total_chars = 0; + for (int p = 0; p < len; ) { p += kbUtf8CharBytesAt(buf, p, len); total_chars++; } + const int last_line = first_line + prev_lines - 1; + int last_chars = total_chars - last_line * cpl; + if (last_chars < 0) last_chars = 0; + if (last_chars > cpl) last_chars = cpl; + if (cursor_line == last_line) last_chars++; + if (last_chars * cw + tw + cw <= display.width()) { + display.setCursor(display.width() - tw, (prev_lines - 1) * lh); + display.print(tag); + } + } } display.fillRect(0, sep_y, display.width(), display.sepH()); @@ -808,7 +855,7 @@ struct KeyboardWidget { int x = bx + i * seg_w; if (i == accent_sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(x + 1, by + 1, seg_w - 1, bh - 2); + display.fillSoftRect(x + 1, by + 1, seg_w - 1, bh - 2); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index 0c80a52d..1cd93781 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -154,6 +154,46 @@ class MessagesScreen : public UIScreen { // reflow as messages arrive. struct HistScroll { bool need; int reserve; long total_px, scroll_px; int view_px; }; + // Compact DM bubble text: -1 when it fits on one line beside the marker and + // age (tail_w), else the byte offset its second line starts at -- the last + // space that keeps line 1 within line_w, or a hard cut if there is none. + static int dmSplit(DisplayDriver& d, const char* body, int line_w, int tail_w) { + if ((int)d.getTextWidth(body) + tail_w <= line_w) return -1; + const uint8_t* p = (const uint8_t*)body; + int w = 0, last_sp = -1; + while (*p) { + const uint8_t* cp_start = p; + uint32_t cp = DisplayDriver::decodeCodepoint(p); + int off = (int)(cp_start - (const uint8_t*)body); + if (cp == ' ' && w <= line_w) last_sp = off; + w += d.getCodepointWidth(cp); + if (w > line_w) return last_sp > 0 ? last_sp + 1 : (off > 0 ? off : (int)(p - (const uint8_t*)body)); + } + return -1; + } + + // Scrollbar metrics for boxes of varying height (h(idx, reserve) px each, + // `gap` px apart): the same fit test as the portrait path below. + template + HistScroll computeHistScrollH(DisplayDriver& display, int count, int scroll, int hist_start_y, int cby, + int gap, GetH h) { + HistScroll r{}; + r.view_px = cby - (hist_start_y + 1); + if (r.view_px < 1) r.view_px = 1; + const int col = scrollIndicatorColWidth(display); + int cur = hist_start_y, fit = 0; + for (int i = 0; i < count; i++) { int bh = h(i, 0); if (cur + bh > cby) break; fit++; cur += bh + gap; } + r.need = fit < count; + r.reserve = r.need ? col : 0; + if (r.need) + for (int i = 0; i < count; i++) { + long ext = h(i, r.reserve) + gap; + r.total_px += ext; + if (i < scroll) r.scroll_px += ext; + } + return r; + } + // `getBody(idx)` returns the body text for list item idx (the part that wraps), // or nullptr to fall back to a fixed 2-line box. Must return exactly what the // real per-item render pass wraps (sender split off, reply prefix stripped) -- @@ -169,7 +209,7 @@ class MessagesScreen : public UIScreen { if (r.view_px < 1) r.view_px = 1; const int col = scrollIndicatorColWidth(display); - if (!portrait) { // uniform 2-line boxes → exact pixel math + if (!portrait) { // uniform boxes → exact pixel math const int box = fixed_bh + 1; r.total_px = (long)count * box; r.scroll_px = (long)scroll * box; @@ -278,11 +318,22 @@ class MessagesScreen : public UIScreen { _phase = MSG_PICK; } + // The keyboard's field label while writing: who it goes to. + char _kb_prompt[16]; + void setComposePrompt() { + const char* to = _sel_contact.name; + ChannelDetails ch; + if (_sending_to_channel && the_mesh.getChannel(_sel_channel_idx, ch)) to = ch.name; + snprintf(_kb_prompt, sizeof(_kb_prompt), "%.12s", to); + _kb->prompt = _kb_prompt; + } + // Recipient chosen while sharing — open the keyboard with the prepared text. void beginShareCompose(bool channel) { _sending_to_channel = channel; _reply_mode = false; _kb->begin(_share_text); + setComposePrompt(); _phase = KEYBOARD; } @@ -641,6 +692,7 @@ class MessagesScreen : public UIScreen { } _login_mode = true; _kb->begin("", 15); // room/repeater password: max 15 chars + _kb->prompt = "Password"; _kb->clearPlaceholders(); // {loc}/{time} are for messages, not a password _phase = KEYBOARD; } @@ -1317,24 +1369,55 @@ public: // Portrait e-ink (height > width): variable-height boxes that show the full // wrapped message text. All other displays/orientations: compact 2-line boxes. bool portrait_expand = (display.height() > display.width()); + // A DM bubble on a compact screen carries no sender line: one line of + // text with the delivery / hop marker and the age at its end, growing to + // two lines (marker and age ending the second) when the text is longer. + // Rooms keep the author line above the text. + const bool compact_dm = !is_room && !portrait_expand; const int MAX_VIS_BOXES = 8; int box_ys[MAX_VIS_BOXES], box_hs[MAX_VIS_BOXES], n_vis = 0; + + // The marker + age strip at a compact bubble's end, and its width. + struct Tail { int ack_w, age_w, mk_gap, hops, w; char age[6]; }; + auto tailOf = [&](const DmHistEntry& e) { + Tail t; + geo::fmtAgeShort(t.age, sizeof(t.age), now_ts, e.timestamp ? e.timestamp : now_ts); + t.age_w = t.age[0] ? display.getTextWidth(t.age) + 3 : 0; + t.hops = !e.outgoing ? (e.path_len & 63) : 0; + t.ack_w = e.outgoing ? (3 + ackGlyphWidth(display, _history.dmEffectiveStatus(e), e.attempt + 1)) + : (t.hops > 0 ? (3 + miniIconNumberWidth(display, t.hops)) : 0); + t.mk_gap = (t.ack_w > 0 && t.age_w > 0) ? 4 : 0; + t.w = t.ack_w + t.mk_gap + t.age_w + (t.age_w ? 0 : 3); + return t; + }; + auto bodyAt = [&](int idx, char* sb, size_t sbn) -> const char* { + int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, idx); + if (rp < 0) return nullptr; + return skipReplyPrefix(dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name, sb, sbn)); + }; + // Height of compact bubble idx at a given scrollbar reserve. + auto compactH = [&](int idx, int rsv) { + int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, idx); + if (rp < 0) return lh + 1; + char sb[33]; + const char* body = bodyAt(idx, sb, sizeof(sb)); + int line_w = bubbleMaxW(display, display.width() - rsv) - 6; + return dmSplit(display, body ? body : "", line_w, tailOf(_history.dmAtPos(rp)).w) < 0 ? lh + 1 : 2 * lh + 1; + }; + // Fixed-track scrollbar metrics + a stable gutter reserve (decided by a // whole-list fit test, not last frame's visible count) so message boxes // don't reflow their width as messages arrive. - HistScroll hs = computeHistScroll(display, portrait_expand, dm_count, _dm_hist_scroll, + HistScroll hs = compact_dm + ? computeHistScrollH(display, dm_count, _dm_hist_scroll, hist_start_y, cby, 1, compactH) + : computeHistScroll(display, portrait_expand, dm_count, _dm_hist_scroll, hist_start_y, cby, lh, [&](int idx) -> const char* { - int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, idx); - if (rp < 0) return nullptr; // Must match the per-item body extraction below (dmDisplayParts + // skipReplyPrefix) exactly, or this sizing pass and the real render - // pass disagree on wrapped line count for room posts -- previously - // this returned the raw "Sender: text" unsplit, wrapping the sender - // name in with the body and mis-sizing the box. + // pass disagree on wrapped line count for room posts. char tmp_sender[33]; - return skipReplyPrefix(dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name, - tmp_sender, sizeof(tmp_sender))); + return bodyAt(idx, tmp_sender, sizeof(tmp_sender)); }); int reserve = hs.reserve; { @@ -1347,7 +1430,7 @@ public: const int box_gap = portrait_expand ? 2 : 1; int cur_y = cby - box_gap; // reserve the same gap against compose as between boxes for (int ii = 0; ii < MAX_VIS_BOXES && (_dm_hist_scroll + ii) < dm_count; ii++) { - int bh = fixed_bh; + int bh = compact_dm ? compactH(_dm_hist_scroll + ii, reserve) : fixed_bh; if (portrait_expand) { int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_scroll + ii); if (rp >= 0) { @@ -1377,7 +1460,54 @@ public: const DmHistEntry& e = _history.dmAtPos(ring_pos); char sender_buf[33]; const char* body = skipReplyPrefix(dmDisplayParts(e, is_room, filtered_name, sender_buf, sizeof(sender_buf))); - const char* sender = sender_buf; + // A DM has two people: the header names the contact and the side a + // bubble sits on says who wrote it, so bubbles carry only the delivery + // or hop marker and the age. Rooms keep each post's author. + const char* sender = is_room ? sender_buf : ""; + + if (compact_dm) { + Tail t = tailOf(e); + int full_avail = display.width() - reserve; + int max_w = bubbleMaxW(display, full_avail); + int line_w = max_w - 6; + int split = dmSplit(display, body, line_w, t.w); + char l1[MSG_TEXT_BUF]; + const char* l2 = nullptr; + int body_w; + if (split < 0) { + int raw = display.getTextWidth(body); + body_w = (raw > line_w - t.w ? line_w - t.w : raw) + 6 + t.w; + } else { + int n = split; + while (n > 0 && body[n - 1] == ' ') n--; + if (n > (int)sizeof(l1) - 1) n = sizeof(l1) - 1; + memcpy(l1, body, n); l1[n] = '\0'; + l2 = body + split; + int w1 = display.getTextWidth(l1), w2 = display.getTextWidth(l2); + if (w2 > line_w - t.w) w2 = line_w - t.w; + body_w = (w1 > w2 + t.w ? w1 : w2 + t.w) + 6; + } + BubbleBox box = computeBubbleBox(full_avail, max_w, e.outgoing, 0, body_w); + drawHistRowFrame(display, box.x, box.w, y, bh, lh, sel); + // Marker and age end the last line of text. + const int ty = y + 1 + (l2 ? lh : 0); + const int mark_x = box.x + box.w - t.w + 3; + if (e.outgoing) drawAckGlyph(display, mark_x, ty, _history.dmEffectiveStatus(e), e.attempt + 1); + else if (t.hops > 0) miniIconDrawNumber(display, mark_x, ty, t.hops); + if (t.age[0]) { display.setCursor(box.x + box.w - t.age_w, ty); display.print(t.age); } + // Only the last line marquees (one shared marquee slot), and not + // while the context menu is over it. + bool body_marquee = sel && !_ctx_menu.active; + int r_body; + if (!l2) { + r_body = display.drawTextEllipsized(box.x + 3, y + 1, box.w - 6 - t.w, body, body_marquee); + } else { + display.drawTextEllipsized(box.x + 3, y + 1, box.w - 6, l1, false); + r_body = display.drawTextEllipsized(box.x + 3, y + lh + 1, box.w - 6 - t.w, l2, body_marquee); + } + if (body_marquee && r_body > 0) mq_delay = r_body; + continue; + } // e.timestamp==0 shouldn't happen (storeDMMsg() falls back to receipt // time at write time), but if it ever does, show the receipt time @@ -1399,7 +1529,7 @@ public: : (in_hop_count > 0 ? (3 + miniIconNumberWidth(display, in_hop_count)) : 0); // A little air between a marker and the age, so "12" + "1s" doesn't read as "121s". int mk_gap = (ack_w > 0 && age_w > 0) ? 2 : 0; - int header_w = 3 + display.getTextWidth(sender) + ack_w + mk_gap + age_w + 3; + int header_w = 3 + (sender[0] ? display.getTextWidth(sender) : -3) + ack_w + mk_gap + age_w + 3; int body_w, nl = 0; if (portrait_expand) { display.translateUTF8ToBlocks(s_wrap_trans, body, sizeof(s_wrap_trans)); @@ -1422,14 +1552,15 @@ public: // actually got -- not after its full width, which would run it into the age. int name_avail = box.w - 6 - age_w - ack_w - mk_gap; if (name_avail < display.getCharWidth()) name_avail = display.getCharWidth(); - int name_w = display.getTextWidth(sender); + int name_w = sender[0] ? display.getTextWidth(sender) : 0; if (name_w > name_avail) name_w = name_avail; - display.drawTextEllipsized(box.x + 3, y + 1, name_avail, sender); + if (sender[0]) display.drawTextEllipsized(box.x + 3, y + 1, name_avail, sender); + const int mark_x = box.x + 3 + (name_w ? name_w + 3 : 0); if (e.outgoing) { // delivery marker after "Me" - int gx = box.x + 3 + name_w + 3; + int gx = mark_x; drawAckGlyph(display, gx, y + 1, _history.dmEffectiveStatus(e), e.attempt + 1); } else if (in_hop_count > 0) { // hop count after the sender name - int gx = box.x + 3 + name_w + 3; + int gx = mark_x; miniIconDrawNumber(display, gx, y + 1, in_hop_count); } if (age[0]) { display.setCursor(box.x + box.w - age_w, y + 1); display.print(age); } @@ -1627,7 +1758,7 @@ public: : (in_hop_count > 0 ? (3 + miniIconNumberWidth(display, in_hop_count)) : 0); // A little air between a marker and the age, so "12" + "1s" doesn't read as "121s". int mk_gap = (ack_w > 0 && age_w > 0) ? 2 : 0; - int header_w = 3 + display.getTextWidth(sender) + ack_w + mk_gap + age_w + 3; + int header_w = 3 + (sender[0] ? display.getTextWidth(sender) : -3) + ack_w + mk_gap + age_w + 3; int body_w, nl = 0; if (portrait_expand) { display.translateUTF8ToBlocks(s_wrap_trans, body, sizeof(s_wrap_trans)); @@ -1825,6 +1956,7 @@ public: if (sel == 0) { _login_mode = true; _kb->begin("", 15); // room/repeater password: max 15 chars + _kb->prompt = "Password"; _kb->clearPlaceholders(); // {loc}/{time} are for messages, not a password _phase = KEYBOARD; } else if (sel == _ctx_pin_idx) { @@ -1910,6 +2042,7 @@ public: // _login_mode handler), whether or not login itself succeeds. _login_mode = true; _kb->begin("", 15); // room/repeater password: max 15 chars + _kb->prompt = "Password"; _kb->clearPlaceholders(); _phase = KEYBOARD; return true; @@ -2377,6 +2510,7 @@ public: if (c == KEY_ENTER) { if (_msg_sel == 0) { _kb->begin(_reply_mode ? _reply_prefix : ""); + setComposePrompt(); kbAddSensorPlaceholders(*_kb, &sensors); _phase = KEYBOARD; return true; diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 099dc886..6369522c 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -497,8 +497,10 @@ class NearbyScreen : public UIScreen, protected NearbyModel { display.setCursor(2, hdr + step); display.print(buf); snprintf(buf, sizeof(buf), "SNR: %.1f dB", e.snr_x4 / 4.0f); display.setCursor(2, hdr + step * 2); display.print(buf); + drawSignalBars(display, display.width() - 2, hdr + step * 2, e.snr_x4); // how we hear them snprintf(buf, sizeof(buf), "Rem: %.1f dB", e.remote_snr_x4 / 4.0f); display.setCursor(2, hdr + step * 3); display.print(buf); + drawSignalBars(display, display.width() - 2, hdr + step * 3, e.remote_snr_x4); // how they hear us display.setCursor(2, hdr + step * 4); display.print(e.is_known ? "Status: known" : "Status: new"); } @@ -631,10 +633,15 @@ public: char empty[24]; if (_source == SRC_SCAN) { const char* msg; - if (_scanning) msg = "No replies yet"; // fits 128 px; the header already says SCANNING + if (_scanning) { // the header already says SCANNING: just show it's listening + display.drawTextCentered(display.width() / 2, display.height() / 2 - display.lineStep() / 2, "No replies yet"); + int r = drawLoadingDots(display, display.width() / 2, display.height() / 2 + display.lineStep() + 2); + if (mq_delay <= 0 || r < mq_delay) mq_delay = r; + msg = nullptr; + } else if (flt) { snprintf(empty, sizeof(empty), "No %s nodes", flt); msg = empty; } else msg = "No nodes found"; - display.drawTextCentered(display.width() / 2, display.height() / 2, msg); + if (msg) display.drawTextCentered(display.width() / 2, display.height() / 2, msg); } else { const char* hint; if (_pick_admin_target && !flt) { snprintf(empty, sizeof(empty), "Nothing to admin"); hint = "Repeaters & rooms"; } @@ -675,8 +682,9 @@ public: display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); char right[10]; - if (_source == SRC_SCAN) { - snprintf(right, sizeof(right), "%d", (int)e.rssi); + if (_source == SRC_SCAN) { // live scan: how well we hear it, as bars + drawSignalBars(display, display.width() - reserve - 2, y, e.snr_x4); + right[0] = '\0'; } else if (_sort == SORT_TIME) { geo::fmtAgeShort(right, sizeof(right), rtc_clock.getCurrentTime(), e.lastmod); if (!right[0]) snprintf(right, sizeof(right), "?"); // unknown / RTC not synced @@ -684,7 +692,7 @@ public: if (e.dist_km >= 0.0f) geo::fmtDist(right, sizeof(right), e.dist_km, useImperial()); else strncpy(right, "?GPS", sizeof(right)); } - display.drawTextRightAlign(display.width() - reserve - 2, y, right); + if (right[0]) display.drawTextRightAlign(display.width() - reserve - 2, y, right); }); } diff --git a/examples/companion_radio/ui-new/PopupMenu.h b/examples/companion_radio/ui-new/PopupMenu.h index 943bfbc5..ec2c7732 100644 --- a/examples/companion_radio/ui-new/PopupMenu.h +++ b/examples/companion_radio/ui-new/PopupMenu.h @@ -164,7 +164,7 @@ struct PopupMenu { // Stops short of the scroll-indicator gutter, like every other list's // selection bar (e.g. drawList's row width - reserve) — otherwise the // bar paints over the indicator's column instead of framing it. - display.fillRect(bx + 1, py - 1, bw - 2 - arrow_w, item_h); + display.fillSoftRect(bx + 2, py - 1, bw - 4 - arrow_w, item_h); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); diff --git a/examples/companion_radio/ui-new/RingtoneEditorScreen.h b/examples/companion_radio/ui-new/RingtoneEditorScreen.h index 27765ff3..6a27c018 100644 --- a/examples/companion_radio/ui-new/RingtoneEditorScreen.h +++ b/examples/companion_radio/ui-new/RingtoneEditorScreen.h @@ -110,7 +110,7 @@ public: } if (sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(cx, notes_y, cell_w - 1, cell_h); + display.fillSoftRect(cx, notes_y, cell_w - 1, cell_h); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); diff --git a/examples/companion_radio/ui-new/SatellitesScreen.h b/examples/companion_radio/ui-new/SatellitesScreen.h index 5b6ab789..fa81b53f 100644 --- a/examples/companion_radio/ui-new/SatellitesScreen.h +++ b/examples/companion_radio/ui-new/SatellitesScreen.h @@ -192,9 +192,9 @@ public: g->expire(); if (!g->sentences || millis() - g->last_ms > 5000) { bool on = _task->getGPSState(); - display.drawTextCentered(cx, top + lh, on ? "Waiting for GPS..." : "GPS is off"); - if (!on) display.drawTextCentered(cx, top + 2 * lh + 2, "Home > GPS page"); - return 1000; + display.drawTextCentered(cx, top + lh, on ? "Waiting for GPS" : "GPS is off"); + if (!on) { display.drawTextCentered(cx, top + 2 * lh + 2, "Home > GPS page"); return 1000; } + return drawLoadingDots(display, cx, top + 3 * lh + 4); } if (_signal) renderSignal(display, *g, top); else renderSky(display, *g, top); diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index a2ccf4a8..82fa785b 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -790,7 +790,7 @@ public: if (res == KeyboardWidget::DONE) { // Entered twice: a typo here would lock the owner out. if (strlen(_kb->buf) < 4) { - _kb->pin_prompt = "Min 4"; + _kb->prompt = "Min 4"; } else if (!_lock_pass_first[0]) { strncpy(_lock_pass_first, _kb->buf, sizeof(_lock_pass_first) - 1); _lock_pass_first[sizeof(_lock_pass_first) - 1] = '\0'; @@ -853,6 +853,7 @@ public: } else if (sel == 1 && _scope_action_idx >= 1) { // Rename _scope_rename_idx = _scope_action_idx; _kb->begin(sl.name((uint8_t)_scope_action_idx), 23); + _kb->prompt = "Scope"; _kb->clearPlaceholders(); } else if (sel == 2 && _scope_action_idx >= 1) { // Delete -- confirm first _scope_action_menu.beginConfirm("Delete scope?", "Delete"); @@ -869,6 +870,7 @@ public: if (_scope_mgmt_sel == sl.totalCount()) { // "+ Add scope" _scope_rename_idx = -1; _kb->begin("", 23); + _kb->prompt = "Scope"; _kb->clearPlaceholders(); } else { _scope_action_idx = _scope_mgmt_sel; @@ -910,6 +912,7 @@ public: switch (_picker.onSelected(_picker.menu.selectedIndex(), p, radioTarget(p))) { case RadioPresetPicker::START_SAVE: _kb->begin("", (int)sizeof(p->user_radio_presets[0].name) - 1); + _kb->prompt = "Preset name"; _kb->clearPlaceholders(); // {loc}/{time} are for messages, not preset names break; case RadioPresetPicker::APPLIED: @@ -1038,6 +1041,7 @@ public: if (_selected == DEVICE_NAME && p && enter) { _edit_name = true; _kb->begin(the_mesh.getNodeName(), (int)sizeof(p->node_name) - 1); + _kb->prompt = "Node name"; _kb->clearPlaceholders(); // a device name is literal, not a message return true; } @@ -1140,6 +1144,7 @@ public: // can't overflow it on save. _kb->begin(p ? p->custom_msgs[slot] : "", p ? (int)sizeof(p->custom_msgs[slot]) - 1 : KB_MAX_LEN); + _kb->prompt = "Quick msg"; kbAddSensorPlaceholders(*_kb, &sensors); return true; } diff --git a/examples/companion_radio/ui-new/TabBar.h b/examples/companion_radio/ui-new/TabBar.h index 284c6c91..ae184133 100644 --- a/examples/companion_radio/ui-new/TabBar.h +++ b/examples/companion_radio/ui-new/TabBar.h @@ -18,7 +18,7 @@ inline void drawPill(DisplayDriver& display, int x, int w, const char* label, bo int lh = display.getLineHeight(); if (active) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(x, 0, w, lh + 1); + display.fillSoftRect(x, 0, w, lh + 1); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); @@ -27,96 +27,64 @@ inline void drawPill(DisplayDriver& display, int x, int w, const char* label, bo display.setColor(DisplayDriver::LIGHT); } -// Truncate `label` to fit within `max_w` pixels of text (no padding). Similar -// intent to DisplayDriver::drawTextEllipsized (which can't be reused directly -// here: it draws immediately at a fixed x, but a tab pill needs the truncated -// width known *before* drawing, to size/centre the pill), but verifies the -// fit by re-measuring the actual "text..." candidate at each step, rather -// than estimating via a separately-measured ellipsis width -- font kerning -// between the last real glyph and the first dot can make a single-pass -// estimate overshoot by a px or two, which was enough to visibly spill the -// dots into whatever sits just past this tab (a neighbour, a reserved icon -// like the context-menu hint). Returns the resulting text's pixel width -// (always <= max_w, unless even a bare "..." doesn't fit); writes the -// (possibly truncated) text into `out`. -inline int ellipsize(DisplayDriver& display, const char* label, int max_w, char* out, size_t out_sz) { - strncpy(out, label, out_sz - 1); - out[out_sz - 1] = '\0'; - int w = display.getTextWidth(out); - if (w <= max_w) return w; - - int len = (int)strlen(out); - char trial[40]; - while (len > 0) { - // Strip an orphaned UTF-8 lead byte before trying this length. - while (len > 0 && ((uint8_t)out[len - 1] & 0xC0) == 0xC0) len--; - if (len == 0) break; - memcpy(trial, out, len); - strcpy(trial + len, "..."); - if (display.getTextWidth(trial) <= max_w) { - strncpy(out, trial, out_sz - 1); - out[out_sz - 1] = '\0'; - return display.getTextWidth(out); - } - len--; +// Small solid triangle pointing left (dir < 0) or right, 3 px wide and 5 +// tall, centred on row y_mid: "more tabs this way". +inline void drawEdgeArrow(DisplayDriver& display, int x, int y_mid, int dir) { + display.setColor(DisplayDriver::LIGHT); + for (int c = 0; c < 3; c++) { + int h = dir < 0 ? c * 2 + 1 : 5 - c * 2; + display.fillRect(x + c, y_mid - h / 2, 1, h); } - strncpy(out, "...", out_sz - 1); - out[out_sz - 1] = '\0'; - return display.getTextWidth(out); } -// Header as a circular tab bar: the active tab sits centred as a filled pill, -// neighbours fan out either side and wrap around. A neighbour that doesn't -// fully fit in the space remaining on its side is drawn truncated with an -// ellipsis instead of being skipped, so the immediate left/right tabs stay -// visible (just shortened) rather than vanishing on a narrow display or a -// long active label -- only once even an ellipsis wouldn't fit does that side -// stop. `right_reserve` keeps any trailing decoration the caller draws -// afterwards (a context-menu hint, a counter) clear of the rightmost tab. -// Also draws the header separator line. +// Header as a circular tab bar: the active tab sits centred as a soft pill and +// the neighbours that fit whole fan out either side. When some tabs don't fit, +// a small arrow at each edge says there are more (the bar wraps around, so +// both ways lead to them) instead of squeezing in cut-off "Sn..." labels. +// `right_reserve` keeps any trailing decoration the caller draws afterwards +// (a context-menu hint, a counter) clear. Also draws the header separator. inline void draw(DisplayDriver& display, const char* const* labels, int count, int active, int right_reserve = 0) { const int pad = 3, gap = 2; - const int min_w = pad * 2 + 6; // smallest pill worth drawing (padding + a couple px of text) - char buf[24]; + const int aw = display.getTextWidth(labels[active]) + pad * 2; + const int ax = display.width() / 2 - aw / 2; + const int rx_limit = display.width() - right_reserve; + + // Lays the neighbours out (and draws them when `paint`), keeping `edge` px + // free at both ends; returns how many tabs are on screen and the free space + // left at each end. + struct Fit { int shown, left_free, right_free; }; + auto layout = [&](int edge, bool paint) { + int lx = ax - gap, rx = ax + aw + gap, shown = 1; + bool lfit = true, rfit = true; + for (int k = 1; k <= count / 2 && (lfit || rfit); k++) { + int li = (active - k + count) % count; + int ri = (active + k) % count; + if (lfit) { + int w = display.getTextWidth(labels[li]) + pad * 2; + if (lx - w >= edge) { if (paint) drawPill(display, lx - w, w, labels[li], false); lx -= w + gap; shown++; } + else lfit = false; + } + // Skip the right side when it lands on the same tab as the left (the + // single opposite tab on an even count) so it isn't drawn twice. + if (rfit && ri != li) { + int w = display.getTextWidth(labels[ri]) + pad * 2; + if (rx + w <= rx_limit - edge) { if (paint) drawPill(display, rx, w, labels[ri], false); rx += w + gap; shown++; } + else rfit = false; + } + } + return Fit{ shown, lx + gap, rx_limit - rx + gap }; + }; - int aw = display.getTextWidth(labels[active]) + pad * 2; - int ax = display.width() / 2 - aw / 2; drawPill(display, ax, aw, labels[active], true); - - int lx = ax - gap; - int rx = ax + aw + gap; - int rx_limit = display.width() - right_reserve; - bool lfit = true, rfit = true; - for (int k = 1; k <= count / 2 && (lfit || rfit); k++) { - int li = (active - k + count) % count; - int ri = (active + k) % count; - if (lfit) { - int avail = lx; // space from x=0 to the running left edge - if (avail >= min_w) { - int tw = ellipsize(display, labels[li], avail - pad * 2, buf, sizeof(buf)); - int w = tw + pad * 2; - // ellipsize() targets max_w but can overshoot it by a px or two (the - // ellipsis is re-measured as a whole, so kerning against the last real - // character isn't accounted for) -- clamp so the pill can never spill - // past its allotted space (into the screen edge, or a reserved icon - // on the other side). - if (w > avail) w = avail; - drawPill(display, lx - w, w, buf, false); - lx = lx - w - gap; - } else lfit = false; - } - // Skip the right side when it lands on the same tab as the left (the - // single opposite tab on an even count) so it isn't drawn twice. - if (rfit && ri != li) { - int avail = rx_limit - rx; - if (avail >= min_w) { - int tw = ellipsize(display, labels[ri], avail - pad * 2, buf, sizeof(buf)); - int w = tw + pad * 2; - if (w > avail) w = avail; // never spill past rx_limit (e.g. into a context-menu icon) - drawPill(display, rx, w, buf, false); - rx += w + gap; - } else rfit = false; - } + Fit f = layout(0, false); + if (f.shown < count && f.left_free < 4 && f.right_free < 4) + f = layout(5, true); // no room for an arrow anywhere: give up a tab for them + else + layout(0, true); + if (f.shown < count) { // more tabs off screen: an arrow wherever there's room + int y_mid = display.getLineHeight() / 2; + if (f.left_free >= 4) drawEdgeArrow(display, 0, y_mid, -1); + if (f.right_free >= 4) drawEdgeArrow(display, rx_limit - 3, y_mid, +1); } display.setColor(DisplayDriver::LIGHT); display.fillRect(0, display.headerH() - display.sepH(), display.width(), display.sepH()); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index f6c3b055..c57a1752 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -52,20 +52,9 @@ #define UI_RECENT_LIST_SIZE 4 #endif -// The sim's D-pad + dedicated OK/Enter key behaves like a joystick board -// (a short Enter press opens Settings/Tools/Messages -- see the -// KEY_ENTER && _page==... handlers below; holding it separately reaches -// KEY_CONTEXT_MENU via handleLongPress(), same as a real joystick board's -// long-press) -- without SIM_PLATFORM here, this would fall to the -// touchscreen-board wording below, which describes a different, and for -// this input method simply wrong, interaction. -#if UI_HAS_JOYSTICK || defined(SIM_PLATFORM) - #define PRESS_LABEL "press Enter" -#else - #define PRESS_LABEL "long press" -#endif #include "icons.h" +#include "../ui-core/Lettering.h" // boot splash wordmark + lettering (shared with the L2) #include "GfxUtils.h" // gfx::drawLine — connects trail points on the Home map preview // Blinking status indicators: on for the first half of a 4 s cycle, but e-ink @@ -74,68 +63,77 @@ static inline bool blinkOn() { return Features::BLINK_INDICATORS ? ((millis() % 4000) < 2000) : true; } +// Boot splash, as on the L2 (ui-lvgl/Splash.h, same lettering from +// ui-core/Lettering.h): the MeshCore wordmark rising into place, "solo" in its +// lettering once it has landed, the Solo version, upstream version + build date, and three dots +// rising in turn while the device starts. E-ink gets the still final frame. class SplashScreen : public UIScreen { UITask* _task; - unsigned long dismiss_after; - char _version_info[12]; - char _solo_ver[12]; + unsigned long _start, dismiss_after; + char _solo_ver[24]; + char _line2[32]; + + // A word in the wordmark's lettering, 1:1, top-left at (x, y). + static void drawLettering(DisplayDriver& d, const char* text, int x, int y, int gap) { + for (const char* p = text; *p; p++) { + char c = (char)tolower((unsigned char)*p); + int cw = lettering::charW(c); + if (cw < 0) continue; + for (int r = 0; r < lettering::LOGO_H; r++) + for (int col = 0; col < cw; ) { // runs of inked pixels as one rect + if (!lettering::inked(c, col, r)) { col++; continue; } + int e = col; + while (e + 1 < cw && lettering::inked(c, e + 1, r)) e++; + d.fillRect(x + col, y + r, e - col + 1, 1); + col = e + 1; + } + x += cw + gap; + } + } public: SplashScreen(UITask* task) : _task(task) { - // MeshCore upstream version shown large (e.g. "1.15") - strncpy(_version_info, MESHCORE_VERSION, sizeof(_version_info) - 1); - _version_info[sizeof(_version_info) - 1] = '\0'; - - // Solo firmware version: strip the commit-hash suffix build.sh always - // appends as the LAST dash-segment (v1.15-solo.1-abcdef -> v1.15-solo.1). - // Must be the last dash, not the first: a tag like v1.21-rc1 has a dash - // of its own before the commit hash gets appended. - const char *ver = FIRMWARE_VERSION; - const char *dash = strrchr(ver, '-'); - int plen = dash ? (int)(dash - ver) : (int)strlen(ver); - if (plen >= (int)sizeof(_solo_ver)) plen = sizeof(_solo_ver) - 1; - memcpy(_solo_ver, ver, plen); - _solo_ver[plen] = '\0'; - - dismiss_after = millis() + BOOT_SCREEN_MILLIS; + lettering::soloVersion(_solo_ver, sizeof(_solo_ver)); + char date[16]; + lettering::buildDate(date, sizeof(date)); +#ifdef MESHCORE_VERSION + snprintf(_line2, sizeof(_line2), "%s %s", MESHCORE_VERSION, date); +#else + snprintf(_line2, sizeof(_line2), "%s", date); +#endif + _start = millis(); + dismiss_after = _start + BOOT_SCREEN_MILLIS; } int render(DisplayDriver& display) override { + const bool anim = !display.isEink(); + const unsigned long t = anim ? millis() - _start : 100000UL; // e-ink: the settled frame display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); const int lh = display.getLineHeight(); - const int step = display.lineStep(); + const int cx = display.width() / 2; - // meshcore logo - display.setColor(DisplayDriver::LIGHT); - int logoWidth = 128; - int logo_y = 3; - display.drawXbm((display.width() - logoWidth) / 2, logo_y, meshcore_logo, logoWidth, 13); + // Block from the wordmark down to the second text line, centred above the dots. + const int dots_h = 8; + const int block_h = lettering::LOGO_H * 2 + 5 + lh * 2 + 2; + int top = (display.height() - dots_h - block_h) / 2; + if (top < 0) top = 0; - // version info at sz2 - int ver_y = logo_y + 13 + 2; - display.setTextSize(2); - int lh2 = display.getLineHeight(); - display.drawTextCentered(display.width()/2, ver_y, _version_info); + // Wordmark: rises 6 px into place over the first 400 ms (ease-out). + int rise = 0; + if (t < 400) { int k = 400 - (int)t; rise = (6 * k * k) / (400 * 400); } + display.drawXbm((display.width() - lettering::LOGO_W) / 2, top + rise, lettering::LOGO, lettering::LOGO_W, lettering::LOGO_H); - // build date at sz1, below sz2 version - int date_y = ver_y + lh2 + 2; - display.setTextSize(1); - display.drawTextCentered(display.width()/2, date_y, FIRMWARE_BUILD_DATE); + int y = top + lettering::LOGO_H + 3; + const int solo_w = lettering::textW("solo", 2); + if (t >= 400) drawLettering(display, "solo", cx - solo_w / 2, y, 2); // once the wordmark has landed + y += lettering::LOGO_H + 2; + display.drawTextCentered(cx, y, _solo_ver[0] ? _solo_ver : "dev"); + y += lh + 2; + display.drawTextCentered(cx, y, _line2); -#ifdef FIRMWARE_SOLO_BUILD - int solo_y = date_y + step; - display.fillRect(0, solo_y - 1, display.width(), lh + 2); - display.setColor(DisplayDriver::DARK); - char solo_label[24]; - if (_solo_ver[0]) - snprintf(solo_label, sizeof(solo_label), "Solo %s", _solo_ver); - else - snprintf(solo_label, sizeof(solo_label), "Solo"); - display.drawTextCentered(display.width()/2, solo_y, solo_label); - display.setColor(DisplayDriver::LIGHT); -#endif - - return 1000; + drawLoadingDots(display, cx, display.height() - 2); // as the L2's + return anim ? 40 : 1000; } void poll() override { @@ -722,6 +720,7 @@ public: int render(DisplayDriver& display) override { char tmp[80]; int mq_delay = 0; // >0 while a selected row's name is marquee-scrolling + int anim_ms = 0; // >0 while a hovering page icon is on screen display.setTextSize(1); const int lh = display.getLineHeight(); // line height at sz1 const int step = display.lineStep(); // lh + 2 @@ -752,8 +751,19 @@ public: display.setColor(DisplayDriver::LIGHT); if (_page != LOCK) { + // The time, once the clock is set (the node name before that): the + // name is what others see, the time is what you glance at here. char filtered_name[sizeof(_node_prefs->node_name)]; - display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); + uint32_t now_ts = _rtc->getCurrentTime(); + if (now_ts >= 1000000000UL) { + struct tm lt; + localTm(now_ts, _node_prefs->tz_offset_hours, lt); + int hh = lt.tm_hour; + if (_node_prefs->clock_12h) { hh %= 12; if (hh == 0) hh = 12; } + snprintf(filtered_name, sizeof(filtered_name), "%d:%02d", hh, lt.tm_min); + } else { + display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); + } // Only show the live-power readout when APC is actually controlling power — // not merely when the pref is set. While repeating APC is suppressed and @@ -791,9 +801,15 @@ public: int x = display.width() / 2 - pitch * (n - 1) / 2; for (int i = 0; i < n; i++) { const MiniIcon* ic = pageIcon(order[i]); + if (i == curr_vis) { // the current page: icon knocked out of a soft pill + int pw = icon_w + 4; + if (pw > pitch - 1) pw = pitch - 1; + display.setColor(DisplayDriver::LIGHT); + display.fillSoftRect(x - pw / 2, dots_y - pg_half - 1, pw, pg_half * 2 + 3); + display.setColor(DisplayDriver::DARK); + } if (ic) miniIconDrawCentered(display, x, dots_y, *ic); - if (i == curr_vis) // underline the current page - display.fillRect(x - icon_w / 2, dots_y + pg_half + 1, icon_w, s); + display.setColor(DisplayDriver::LIGHT); x += pitch; } } @@ -822,7 +838,8 @@ public: static const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; static const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; snprintf(buf, sizeof(buf),"%s %d %s %d", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon], 1900 + ti->tm_year); - display.drawTextCentered(display.width() / 2, date_y, buf); + display.setCursor(0, date_y); + display.print(buf); // Alarm armed: a small bell in the top-left corner. The status bar (and // its bell) is hidden on this page, so signal the armed alarm here. Just @@ -876,6 +893,7 @@ public: struct tm* ti = < char buf[12]; bool h12 = _node_prefs && _node_prefs->clock_12h; + // Left-aligned on purpose: the rest of the top row is left for status icons. int date_y = drawClockTime(display, 0, ti, h12, /*show_sec*/false); display.setTextSize(1); static const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; @@ -961,9 +979,8 @@ public: } else if (_page == HomePage::BLUETOOTH) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); - display.drawXbm((display.width() - 32) / 2, content_y, - _task->isSerialEnabled() ? bluetooth_on : bluetooth_off, 32, 32); - const int text_y = content_y + 32 + 3; + anim_ms = drawHoverIcon(display, display.width() / 2, content_y, BIG_BLUETOOTH, !_task->isSerialEnabled()); + const int text_y = content_y + BIG_BLUETOOTH.h + HOVER_GAP; // The pairing PIN is BLE-specific: show it while BLE is on but not yet // bonded. (Gating on a plain isConnected() broke this on dual builds, // where it's hardcoded true.) @@ -972,18 +989,18 @@ public: char pin_buf[16]; snprintf(pin_buf, sizeof(pin_buf), "PIN: %d", the_mesh.getBLEPin()); display.drawTextCentered(display.width() / 2, text_y, pin_buf); - } else if (waiting_for_pair) { + } else if (waiting_for_pair) { // the pairing PIN takes the title's place char pin_buf[16]; snprintf(pin_buf, sizeof(pin_buf), "PIN: %d", the_mesh.getBLEPin()); display.drawTextCentered(display.width() / 2, text_y, pin_buf); - display.drawTextCentered(display.width() / 2, text_y + step, "toggle: " PRESS_LABEL); } else { - display.drawTextCentered(display.width() / 2, text_y, "toggle: " PRESS_LABEL); + // Each icon page carries just its title; Enter acting on it is the same everywhere. + display.drawTextCentered(display.width() / 2, text_y, "Bluetooth"); } } else if (_page == HomePage::ADVERT) { display.setColor(DisplayDriver::LIGHT); - display.drawXbm((display.width() - 32) / 2, content_y, advert_icon, 32, 32); - display.drawTextCentered(display.width() / 2, content_y + 32 + 3, "advert: " PRESS_LABEL); + anim_ms = drawHoverIcon(display, display.width() / 2, content_y, BIG_ADVERT); + display.drawTextCentered(display.width() / 2, content_y + BIG_ADVERT.h + HOVER_GAP, "Advert"); #if ENV_INCLUDE_GPS == 1 } else if (_page == HomePage::GPS) { LocationProvider* nmea = sensors.getLocationProvider(); @@ -1095,8 +1112,8 @@ public: } else if (_page == HomePage::SETTINGS) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, content_y, "Settings"); - display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open"); + anim_ms = drawHoverIcon(display, display.width() / 2, content_y, BIG_SETTINGS); + display.drawTextCentered(display.width() / 2, content_y + BIG_SETTINGS.h + HOVER_GAP, "Settings"); } else if (_page == HomePage::MAP) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); @@ -1137,19 +1154,24 @@ public: } else if (_page == HomePage::TOOLS) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, content_y, "Tools"); - display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open"); + anim_ms = drawHoverIcon(display, display.width() / 2, content_y, BIG_TOOLS); + display.drawTextCentered(display.width() / 2, content_y + BIG_TOOLS.h + HOVER_GAP, "Tools"); } else if (_page == HomePage::QUICK_MSG) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, content_y, "Messages"); + const int ix = (display.width() - BIG_MESSAGES.w) / 2; + anim_ms = drawHoverIcon(display, display.width() / 2, content_y, BIG_MESSAGES); + // Unread count as the usual pill on the bubble's top-right corner. int total_unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount(); if (total_unread > 0) { - char badge[20]; - snprintf(badge, sizeof(badge), _task->getAnyUnreadOverflow() ? "%d+ unread" : "%d unread", total_unread); - display.drawTextCentered(display.width() / 2, content_y + step, badge); + int bw = display.unreadBadgeWidth(total_unread, _task->getAnyUnreadOverflow()); + int bx = ix + BIG_MESSAGES.w + bw / 2; + display.setColor(DisplayDriver::DARK); // a dark ring so it reads over the bubble's border + const int by = content_y - hoverLift(display); // rides on the hovering bubble + display.fillRect(bx - bw - 1, by + 1, bw + 2, lh + 2); + display.drawUnreadBadge(bx, by + 2, total_unread, false, _task->getAnyUnreadOverflow()); } - display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open"); + display.drawTextCentered(display.width() / 2, content_y + BIG_MESSAGES.h + HOVER_GAP, "Messages"); } else if (_page == HomePage::FAVOURITES) { // Grid of pinned contacts. Layout transposes to current orientation: // landscape → 3×2, portrait → 2×3. Selected tile inverts via drawSelectionRow. @@ -1247,20 +1269,11 @@ public: if (_shutdown_init) { display.drawTextCentered(display.width() / 2, content_y + step, "hibernating..."); } else { - display.drawXbm((display.width() - 32) / 2, content_y, power_icon, 32, 32); - const int text_y = content_y + 32 + 3; + anim_ms = drawHoverIcon(display, display.width() / 2, content_y, BIG_POWER); + const int text_y = content_y + BIG_POWER.h + HOVER_GAP; const int lh1 = display.getLineHeight(); - if (text_y + lh1 <= display.height()) { - char hib_hint[32]; - snprintf(hib_hint, sizeof(hib_hint), "hibernate:%s", PRESS_LABEL); - if (display.getTextWidth(hib_hint) < display.width()) { - display.drawTextCentered(display.width() / 2, text_y, hib_hint); - } else { - display.drawTextCentered(display.width() / 2, text_y, "hibernate:"); - if (text_y + step + lh1 <= display.height()) - display.drawTextCentered(display.width() / 2, text_y + step, PRESS_LABEL); - } - } + if (text_y + lh1 <= display.height()) + display.drawTextCentered(display.width() / 2, text_y, "Hibernate"); } } bool auto_adv = _node_prefs && _node_prefs->advert_auto_interval_sec > 0; @@ -1281,6 +1294,7 @@ public: return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret; } int ret = need_blink ? 1000 : 5000; + if (anim_ms > 0 && anim_ms < ret) ret = anim_ms; return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret; } diff --git a/examples/companion_radio/ui-new/icons.h b/examples/companion_radio/ui-new/icons.h index 89813de4..13386f8b 100644 --- a/examples/companion_radio/ui-new/icons.h +++ b/examples/companion_radio/ui-new/icons.h @@ -1,13 +1,14 @@ #pragma once #include +#include #include // ── Scalable mini-icons ────────────────────────────────────────────────────── // Small procedural glyphs (delivery markers, etc.) authored on a 1× pixel grid // and scaled to the current font, so they stay legible on large-font layouts -// (e.g. landscape e-ink renders text at 2×). Distinct from the XBM page icons -// further down, which are fixed-size full bitmaps drawn via drawXbm(). +// (e.g. landscape e-ink renders text at 2×). Distinct from the big page icons +// further down, which are fixed-size 32 px glyphs drawn via bigIconDraw(). // // To add a mini-icon: // 1. Draw it as ASCII-art rows, one string per row (width ≤ 8): any char @@ -500,7 +501,7 @@ MINI_ICON(ICON_SCROLL_DOWN, 5, // ▼ // text never runs under the scrollbar. Mirrors the column math below (5*scale // triangle + 1px gap). inline int scrollIndicatorColWidth(DisplayDriver& d) { - return 5 * miniIconScale(d) + 1; // triangle (5*scale) + 1px gap + return 3 * miniIconScale(d) + 2; // thumb (3*scale) + 2px gap } inline int scrollIndicatorReserve(DisplayDriver& d, int total, int visible) { return (total > visible) ? scrollIndicatorColWidth(d) : 0; @@ -534,37 +535,23 @@ inline void drawScrollIndicatorPx(DisplayDriver& d, int right_x, int top_y, int if (scroll_px < 0) scroll_px = 0; if (scroll_px > span) scroll_px = span; - const int s = miniIconScale(d); - const int col = 5 * s; // triangle / column width - const int th = 3 * s; // triangle height - const int x = right_x - col; // indicator column origin + // A dotted 1 px track down the middle of a 3*scale column and a soft-cornered + // thumb sized to the share of the list on screen. The column is narrower + // than the old arrow-capped one, so rows keep 2 px more for text. + const int s = miniIconScale(d); + const int bar_w = 3 * s; + const int x = right_x - bar_w; + const int mid = x + bar_w / 2; - // Caps are static end-markers, always drawn flush at the very top / bottom of - // the track; the thumb travels in the fixed band between them. (They mark the - // track ends, not "more above/below", so they never vanish at the extremes.) - const int cap = th + 2; // triangle height + 2px gap - const int band_t = top_y + cap; - const int band_b = top_y + track_h - cap; - int band = band_b - band_t; - if (band < s) band = s; + int thumb_h = (int)((long)track_h * view_px / total_px); + if (thumb_h < 4 * s) thumb_h = 4 * s; + if (thumb_h > track_h) thumb_h = track_h; + const int thumb_y = top_y + (int)((long)(track_h - thumb_h) * scroll_px / span); - const int bar_w = 3 * s; // odd width → centres exactly in the 5*s column - const int bar_x = x + (col - bar_w) / 2; - - int thumb_h = (int)((long)band * view_px / total_px); - if (thumb_h < th) thumb_h = th; - if (thumb_h > band) thumb_h = band; - const int thumb_y = band_t + (int)((long)(band - thumb_h) * scroll_px / span); - - // No halo: every caller already keeps its selection bar clear of this column - // (reserve/_reserve), so the markers never need to fight a LIGHT background - // for contrast — and a halo on the bottom arrow had no symmetric clip like - // the top one, so it bled 1px into the container's bottom border. d.setColor(DisplayDriver::LIGHT); - d.fillRect(bar_x, thumb_y, bar_w, thumb_h); - - miniIconDrawTop(d, x, top_y, ICON_SCROLL_UP); - miniIconDrawTop(d, x, top_y + track_h - th, ICON_SCROLL_DOWN); + for (int y = top_y; y < top_y + track_h; y += 2) + if (y < thumb_y - 1 || y > thumb_y + thumb_h) d.fillRect(mid, y, 1, 1); + d.fillSoftRect(x, thumb_y, bar_w, thumb_h); } // Convenience overload anchored to the screen's right edge — the common case @@ -613,6 +600,52 @@ inline int drawList(DisplayDriver& d, int total, int sel, int& scroll, RenderRow return visible; } +// Signal strength as four rising bars, from a LoRa SNR in quarter-dB (the +// unit packets carry). Bars that aren't lit leave a 1 px stub so the scale +// stays readable. Bottom-aligned to a text row at y; right edge at x_right. +// Returns the width used. Draws in the current ink. +inline int signalBarsFromSnr(int snr_x4) { + int snr = snr_x4 / 4; + return snr >= 5 ? 4 : snr >= 0 ? 3 : snr >= -7 ? 2 : snr >= -13 ? 1 : 0; +} +inline int signalBarsWidth(DisplayDriver& d) { int s = miniIconScale(d); return 4 * 2 * s + 3 * s; } +inline int drawSignalBars(DisplayDriver& d, int x_right, int y, int snr_x4) { + const int s = miniIconScale(d); + const int bw = 2 * s, gap = s, w = signalBarsWidth(d); + const int base = y + d.getLineHeight() - 2; // sits on the text baseline + const int lit = signalBarsFromSnr(snr_x4); + int x = x_right - w; + for (int i = 0; i < 4; i++) { + int h = (i + 1) * 2 * s; + if (i < lit) d.fillRect(x, base - h + 1, bw, h); + else d.fillRect(x, base, bw, s); + x += bw + gap; + } + return w; +} + +// Three dots rising in turn, each a beat after the last: the "working on it" +// mark shared by the boot splash and every screen waiting on the radio or GPS. +// Centred on cx with their resting bottom at y_bottom. Returns the redraw +// delay the animation needs; e-ink draws them still and asks for none sooner. +inline int drawLoadingDots(DisplayDriver& d, int cx, int y_bottom) { + const int s = miniIconScale(d); + const int dot = 3 * s, pitch = 9 * s, lift_max = 3 * s; + const bool anim = !d.isEink(); + const unsigned long t = millis(); + d.setColor(DisplayDriver::LIGHT); + for (int i = 0; i < 3; i++) { + int lift = 0; + if (anim) { + long ph = (long)((t + 940UL - 140UL * i) % 940UL); + if (ph < 260) lift = (int)(lift_max * ph / 260); + else if (ph < 520) lift = (int)(lift_max * (520 - ph) / 260); + } + d.fillRect(cx + (i - 1) * pitch - dot / 2, y_bottom - dot - lift, dot, dot); + } + return anim ? 40 : 1000; +} + // Canonical selection bar for a drawList() row: spans the row width minus the // scroll-indicator `reserve`, one pixel short of the row height, anchored one // pixel above `y` (the row's text baseline-top). Call as the first line of a @@ -623,11 +656,10 @@ inline void drawRowSelection(DisplayDriver& d, int y, bool sel, int reserve) { d.drawSelectionRow(0, y - 1, d.width() - reserve, d.lineStep() - 1, sel); } -// ── Big ASCII-art icons (skeleton, not yet used) ───────────────────────────── +// ── Big ASCII-art icons ───────────────────────────── // Same authoring idea as the mini-icons but for full page glyphs up to 32 px -// wide: one uint32_t per row. The existing XBM bitmaps below (logo/bluetooth/…) -// stay hand-encoded; reach for this when adding a *new* big icon so it's -// readable in source. Drawn at 1× (page icons aren't font-scaled). +// wide: one uint32_t per row, readable in source. Drawn at 1× (page icons +// aren't font-scaled). // // To add one: // BIG_ICON(MY_ICON, 16, @@ -651,126 +683,264 @@ struct BigIcon { uint8_t w, h; const uint32_t* rows; }; (uint8_t)(sizeof(name##_rows) / sizeof(uint32_t)), name##_rows } // Draw a packed big icon at 1× with the current ink colour, top-left at (x, y). -inline void bigIconDraw(DisplayDriver& d, int x, int y, const BigIcon& ic) { - for (int r = 0; r < ic.h; r++) - for (int c = 0; c < ic.w; c++) - if (ic.rows[r] & (1u << c)) d.fillRect(x + c, y + r, 1, 1); +// `dim` draws only every other pixel (50% checker): the icon's "off" state. +inline void bigIconDraw(DisplayDriver& d, int x, int y, const BigIcon& ic, bool dim = false) { + for (int r = 0; r < ic.h; r++) { + if (dim) { + for (int c = (r + x + y) & 1; c < ic.w; c += 2) + if (ic.rows[r] & (1u << c)) d.fillRect(x + c, y + r, 1, 1); + continue; + } + for (int c = 0; c < ic.w; ) { // runs of set bits as one rect + if (!(ic.rows[r] & (1u << c))) { c++; continue; } + int e = c; + while (e + 1 < ic.w && (ic.rows[r] & (1u << (e + 1)))) e++; + d.fillRect(x + c, y + r, e - c + 1, 1); + c = e + 1; + } + } } -// 'meshcore', 128x13px -static const uint8_t meshcore_logo [] = { - 0x3c, 0x01, 0xe3, 0xff, 0xc7, 0xff, 0x8f, 0x03, 0x87, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, - 0x3c, 0x03, 0xe3, 0xff, 0xc7, 0xff, 0x8e, 0x03, 0x8f, 0xfe, 0x3f, 0xfe, 0x1f, 0xff, 0x1f, 0xfe, - 0x3e, 0x03, 0xc3, 0xff, 0x8f, 0xff, 0x0e, 0x07, 0x8f, 0xfe, 0x7f, 0xfe, 0x1f, 0xff, 0x1f, 0xfc, - 0x3e, 0x07, 0xc7, 0x80, 0x0e, 0x00, 0x0e, 0x07, 0x9e, 0x00, 0x78, 0x0e, 0x3c, 0x0f, 0x1c, 0x00, - 0x3e, 0x0f, 0xc7, 0x80, 0x1e, 0x00, 0x0e, 0x07, 0x1e, 0x00, 0x70, 0x0e, 0x38, 0x0f, 0x3c, 0x00, - 0x7f, 0x0f, 0xc7, 0xfe, 0x1f, 0xfc, 0x1f, 0xff, 0x1c, 0x00, 0x70, 0x0e, 0x38, 0x0e, 0x3f, 0xf8, - 0x7f, 0x1f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x0e, 0x38, 0x0e, 0x3f, 0xf8, - 0x7f, 0x3f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x1e, 0x3f, 0xfe, 0x3f, 0xf0, - 0x77, 0x3b, 0x87, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xfc, 0x38, 0x00, - 0x77, 0xfb, 0x8f, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xf8, 0x38, 0x00, - 0x73, 0xf3, 0x8f, 0xff, 0x0f, 0xff, 0x1c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x78, 0x7f, 0xf8, - 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfe, 0x3c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x3c, 0x7f, 0xf8, - 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8, -}; +// Home page icons, 32x32, one family: 3 px strokes, rounded ends. Drawn +// through drawHoverIcon() below. Rasterised from vector shapes, so keep any +// redraw to the same stroke width rather than touching single pixels. +// Bluetooth rune +BIG_ICON(BIG_BLUETOOTH, 32, + packRow32("................................"), + packRow32("................................"), + packRow32("..............##................"), + packRow32(".............####..............."), + packRow32(".............#####.............."), + packRow32(".............######............."), + packRow32(".............#######............"), + packRow32(".............#########.........."), + packRow32(".............####.#####........."), + packRow32("........##...####..####........."), + packRow32(".......####..####..####........."), + packRow32("........####.####.#####........."), + packRow32(".........############..........."), + packRow32("..........##########............"), + packRow32("...........########............."), + packRow32("............######.............."), + packRow32("............######.............."), + packRow32("...........########............."), + packRow32("..........##########............"), + packRow32(".........############..........."), + packRow32("........####.####.#####........."), + packRow32(".......####..####..####........."), + packRow32("........##...####..####........."), + packRow32(".............####.#####........."), + packRow32(".............#########.........."), + packRow32(".............#######............"), + packRow32(".............######............."), + packRow32(".............#####.............."), + packRow32(".............####..............."), + packRow32("..............##................"), + packRow32("................................"), + packRow32("................................")); +// advert: a dot between two pairs of arcs +BIG_ICON(BIG_ADVERT, 32, + packRow32("................................"), + packRow32("................................"), + packRow32("................................"), + packRow32("................................"), + packRow32("................................"), + packRow32("................................"), + packRow32("....#......................#...."), + packRow32("...####..................####..."), + packRow32("...###....................###..."), + packRow32("..###......................###.."), + packRow32("..###...##............##...###.."), + packRow32(".###...####..........####...###."), + packRow32(".###...###.....##.....###...###."), + packRow32(".###..###....######....###..###."), + packRow32(".##...###....######....###...##."), + packRow32(".##...###...########...###...##."), + packRow32(".##...###...########...###...##."), + packRow32(".##...###....######....###...##."), + packRow32(".###..###....######....###..###."), + packRow32(".###...###.....##.....###...###."), + packRow32(".###...####..........####...###."), + packRow32("..###...##............##...###.."), + packRow32("..###......................###.."), + packRow32("...###....................###..."), + packRow32("...####..................####..."), + packRow32("....#......................#...."), + packRow32("................................"), + packRow32("................................"), + packRow32("................................"), + packRow32("................................"), + packRow32("................................"), + packRow32("................................")); +// power (Hibernate) +BIG_ICON(BIG_POWER, 32, + packRow32("................................"), + packRow32("................................"), + packRow32("...............##..............."), + packRow32("..............####.............."), + packRow32("..............####.............."), + packRow32("..............####.............."), + packRow32("..............####.............."), + packRow32("..............####.............."), + packRow32(".......##.....####.....##......."), + packRow32("......####....####....####......"), + packRow32(".....####.....####.....####....."), + packRow32("....####......####......####...."), + packRow32("....###.......####.......###...."), + packRow32("....###.......####.......###...."), + packRow32("...###........####........###..."), + packRow32("...###.........##.........###..."), + packRow32("...###....................###..."), + packRow32("...###....................###..."), + packRow32("...###....................###..."), + packRow32("...###....................###..."), + packRow32("...###....................###..."), + packRow32("....###..................###...."), + packRow32("....###..................###...."), + packRow32("....####................####...."), + packRow32(".....####..............####....."), + packRow32("......####............####......"), + packRow32(".......#####........#####......."), + packRow32("........################........"), + packRow32(".........##############........."), + packRow32("...........##########..........."), + packRow32("................................"), + packRow32("................................")); +// gear +BIG_ICON(BIG_SETTINGS, 32, + packRow32("................................"), + packRow32(".............######............."), + packRow32(".............######............."), + packRow32(".............######............."), + packRow32(".......##....######....##......."), + packRow32("......####...######...####......"), + packRow32(".....######################....."), + packRow32("....########################...."), + packRow32("....########################...."), + packRow32(".....######################....."), + packRow32("......####################......"), + packRow32("......#######......#######......"), + packRow32("......######........######......"), + packRow32(".##########..........##########."), + packRow32(".##########..........##########."), + packRow32(".##########..........##########."), + packRow32(".##########..........##########."), + packRow32(".##########..........##########."), + packRow32(".##########..........##########."), + packRow32("......######........######......"), + packRow32("......#######......#######......"), + packRow32("......####################......"), + packRow32(".....######################....."), + packRow32("....########################...."), + packRow32("....########################...."), + packRow32(".....######################....."), + packRow32("......####...######...####......"), + packRow32(".......##....######....##......."), + packRow32(".............######............."), + packRow32(".............######............."), + packRow32(".............######............."), + packRow32("................................")); +// wrench +BIG_ICON(BIG_TOOLS, 32, + packRow32("................................"), + packRow32("................................"), + packRow32(".....................##........."), + packRow32("..................#######......."), + packRow32(".................#######........"), + packRow32("................#######........."), + packRow32("...............#######.........."), + packRow32("...............######.......#..."), + packRow32("...............#####.......##..."), + packRow32("..............#######.....####.."), + packRow32("..............########...#####.."), + packRow32("...............########.#####..."), + packRow32("...............##############..."), + packRow32("...............##############..."), + packRow32("..............##############...."), + packRow32(".............##############....."), + packRow32("............##############......"), + packRow32("...........#######...##........."), + packRow32("..........#######..............."), + packRow32(".........#######................"), + packRow32("........#######................."), + packRow32(".......#######.................."), + packRow32("......#######..................."), + packRow32(".....#######...................."), + packRow32("....#######....................."), + packRow32("....######......................"), + packRow32("....#####......................."), + packRow32(".....###........................"), + packRow32("................................"), + packRow32("................................"), + packRow32("................................"), + packRow32("................................")); +// chat bubble with the loading-dot trio in it +BIG_ICON(BIG_MESSAGES, 32, + packRow32("................................"), + packRow32("................................"), + packRow32("................................"), + packRow32("................................"), + packRow32("................................"), + packRow32("......####################......"), + packRow32("....########################...."), + packRow32("....########################...."), + packRow32("...####..................####..."), + packRow32("...###....................###..."), + packRow32("...###....................###..."), + packRow32("...###....................###..."), + packRow32("...###..####..####..####..###..."), + packRow32("...###..####..####..####..###..."), + packRow32("...###..####..####..####..###..."), + packRow32("...###....................###..."), + packRow32("...###....................###..."), + packRow32("...###....................###..."), + packRow32("...####..................####..."), + packRow32("....########################...."), + packRow32("....########################...."), + packRow32("......####################......"), + packRow32("........#####..................."), + packRow32("........####...................."), + packRow32("........###....................."), + packRow32("........##......................"), + packRow32("........#......................."), + packRow32("................................"), + packRow32("................................"), + packRow32("................................"), + packRow32("................................"), + packRow32("................................")); -static const uint8_t bluetooth_on[] = { - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x30, 0x00, 0x00, - 0x00, 0x3C, 0x00, 0x00, - 0x00, 0x3E, 0x00, 0x00, - 0x00, 0x3F, 0x80, 0x00, - 0x00, 0x3F, 0xC0, 0x00, - 0x00, 0x3B, 0xE0, 0x00, - 0x30, 0x38, 0xF8, 0x00, - 0x3C, 0x38, 0x7C, 0x00, - 0x3E, 0x38, 0x7C, 0x00, - 0x1F, 0xB8, 0xF8, 0x70, - 0x07, 0xF9, 0xF0, 0x78, - 0x03, 0xFF, 0xC0, 0x78, - 0x00, 0xFF, 0x80, 0x3C, - 0x00, 0x7F, 0x07, 0x1C, - 0x00, 0x7E, 0x07, 0x1C, - 0x03, 0xFF, 0x82, 0x1C, - 0x03, 0xFF, 0xC0, 0x78, - 0x07, 0xFB, 0xE0, 0x78, - 0x0F, 0xB8, 0xF8, 0x70, - 0x3E, 0x38, 0x7C, 0x00, - 0x3C, 0x38, 0x7C, 0x00, - 0x38, 0x38, 0xF8, 0x00, - 0x00, 0x39, 0xF0, 0x00, - 0x00, 0x3F, 0xC0, 0x00, - 0x00, 0x3F, 0x80, 0x00, - 0x00, 0x3E, 0x00, 0x00, - 0x00, 0x3C, 0x00, 0x00, - 0x00, 0x38, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, -}; - -static const uint8_t bluetooth_off[] = { - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x03, 0x80, 0x00, - 0x00, 0x03, 0xC0, 0x00, - 0x00, 0x03, 0xE0, 0x00, - 0x38, 0x03, 0xF8, 0x00, - 0x3C, 0x03, 0xFC, 0x00, - 0x3E, 0x03, 0xBF, 0x00, - 0x0F, 0x83, 0x8F, 0x80, - 0x07, 0xC3, 0x87, 0xC0, - 0x03, 0xF0, 0x03, 0xC0, - 0x00, 0xF8, 0x0F, 0x80, - 0x00, 0x7C, 0x0F, 0x00, - 0x00, 0x1F, 0x0E, 0x00, - 0x00, 0x0F, 0x80, 0x00, - 0x00, 0x07, 0xE0, 0x00, - 0x00, 0x07, 0xF0, 0x00, - 0x00, 0x0F, 0xF8, 0x00, - 0x00, 0x3F, 0xBE, 0x00, - 0x00, 0x7F, 0x9F, 0x00, - 0x00, 0xFB, 0x8F, 0xC0, - 0x03, 0xE3, 0x83, 0xE0, - 0x03, 0xC3, 0x87, 0xF0, - 0x03, 0x83, 0x8F, 0xFC, - 0x00, 0x03, 0xBF, 0x3C, - 0x00, 0x03, 0xFC, 0x1C, - 0x00, 0x03, 0xF8, 0x00, - 0x00, 0x03, 0xE0, 0x00, - 0x00, 0x03, 0xC0, 0x00, - 0x00, 0x03, 0x80, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, -}; - -static const uint8_t power_icon[] = { - 0x00, 0x01, 0x80, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, 0x00, - 0x00, 0x33, 0xCC, 0x00, 0x00, 0xF3, 0xCF, 0x00, 0x01, 0xF3, 0xCF, 0x80, - 0x03, 0xF3, 0xCF, 0xC0, 0x07, 0xF3, 0xCF, 0xE0, 0x0F, 0xE3, 0xC7, 0xF0, - 0x1F, 0xC3, 0xC3, 0xF8, 0x1F, 0x83, 0xC1, 0xF8, 0x3F, 0x03, 0xC0, 0xFC, - 0x3E, 0x03, 0xC0, 0x7C, 0x3E, 0x03, 0xC0, 0x7C, 0x7E, 0x01, 0x80, 0x7E, - 0x7C, 0x00, 0x00, 0x3E, 0x7C, 0x00, 0x00, 0x3E, 0x7C, 0x00, 0x00, 0x3E, - 0x7C, 0x00, 0x00, 0x3E, 0x7C, 0x00, 0x00, 0x3E, 0x3E, 0x00, 0x00, 0x7C, - 0x3E, 0x00, 0x00, 0x7C, 0x3F, 0x00, 0x00, 0xFC, 0x1F, 0x80, 0x01, 0xF8, - 0x1F, 0xC0, 0x03, 0xF8, 0x0F, 0xE0, 0x07, 0xF0, 0x0F, 0xF8, 0x1F, 0xF0, - 0x07, 0xFF, 0xFF, 0xE0, 0x03, 0xFF, 0xFF, 0xC0, 0x00, 0xFF, 0xFF, 0x00, - 0x00, 0x3F, 0xFC, 0x00, 0x00, 0x0F, 0xF0, 0x00, -}; - -static const uint8_t advert_icon[] = { -0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, -0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x30, -0x1C, 0x00, 0x00, 0x38, 0x18, 0x00, 0x00, 0x18, 0x30, 0x00, 0x00, 0x0C, -0x30, 0x60, 0x06, 0x0C, 0x60, 0xE0, 0x07, 0x06, 0x61, 0xC0, 0x03, 0x86, -0xE1, 0x81, 0x81, 0x87, 0xC3, 0x07, 0xE0, 0xC3, 0xC3, 0x0F, 0xF0, 0xC3, -0xC3, 0x0F, 0xF0, 0xC3, 0xC3, 0x0F, 0xF0, 0xC3, 0xC3, 0x0F, 0xF0, 0xC3, -0xC3, 0x07, 0xE0, 0xC3, 0xC1, 0x83, 0xC1, 0x83, 0x61, 0x80, 0x01, 0x86, -0x60, 0xC0, 0x03, 0x06, 0x70, 0xE0, 0x07, 0x0E, 0x30, 0x40, 0x02, 0x0C, -0x38, 0x00, 0x00, 0x1C, 0x18, 0x00, 0x00, 0x18, 0x0C, 0x00, 0x00, 0x30, -0x04, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, -0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, -}; +// A Home page icon hovering over its spot: it bobs up to 2 px on a slow +// cosine while a dithered blob below it, its shadow, shrinks as it rises and +// spreads as it sinks. Centred on cx in the 32 px box at y: the glyphs leave +// a row or two blank at the top, so the icon rests 1 px high and the shadow +// fits in the rows the old static icon left above its title. Put the title at +// y + h + HOVER_GAP. E-ink gets the resting frame. Returns ms until the next +// frame is due. +constexpr int HOVER_GAP = 3; +// Where the bob is now: 0 = resting, 1 = top. Anything riding on the icon (a +// badge) lifts by hoverLift() so it moves with it. +inline float hoverPhase(DisplayDriver& d) { + if (d.isEink()) return 0; + const unsigned long period = 2400; + return (1.0f - cosf(6.2831853f * (float)(millis() % period) / period)) * 0.5f; +} +inline int hoverLift(DisplayDriver& d) { return 1 + (int)(2 * hoverPhase(d) + 0.5f); } +inline int drawHoverIcon(DisplayDriver& d, int cx, int y, const BigIcon& ic, bool dim = false) { + const bool anim = !d.isEink(); + const float up = hoverPhase(d); + d.setColor(DisplayDriver::LIGHT); + bigIconDraw(d, cx - ic.w / 2, y - 1 - (int)(2 * up + 0.5f), ic, dim); + // Shadow: three rows, a checker oval with a solid core that fades as it rises. + const int w = (int)(28 - 10 * up + 0.5f), core = (int)(w * 0.5f * (1 - up) + 0.5f); + const int sy = y + ic.h - 1; + for (int r = 0; r < 3; r++) { + const int rw = r == 1 ? w : w - 8, x0 = cx - rw / 2; + for (int x = x0; x < x0 + rw; x++) + if (((x + sy + r) & 1) == 0 || (r == 1 && 2 * (x - cx) + 1 < core && 2 * (cx - x) - 1 < core)) + d.fillRect(x, sy + r, 1, 1); + } + return anim ? 60 : 1000; +} // Favourite marker for a list row, on every screen that lists something // starrable. Reuses the Favourites page's own icon so the two read as one idea. diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 7f6a0f0c..f93436ae 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -188,7 +188,8 @@ public: void drawSelectionRow(int x, int y, int w, int h, bool sel) { setColor(LIGHT); if (sel) { - fillRect(x, y, w, h); + if (w > 2 && h > 2) fillSoftRect(x, y, w, h); // soft corners, like every box + else fillRect(x, y, w, h); setColor(DARK); } }