feat(l1): L2 splash, hovering Home icons, soft selections, two-line DM bubbles

- Splash: the L2 wordmark and lettering, shared through ui-core/Lettering.h,
  rise in with loading dots under the version line (static frame on e-ink).
- Home: the header shows the time once the clock is set; icon pages carry
  only their title. One 32 px icon family (3 px strokes) bobs 2 px over a
  dithered blob shadow that shrinks as it rises; the unread badge rides on
  the Messages bubble; Bluetooth off is the icon at half density.
- Soft corners on every selection, the page pill, tabs and popups;
  dotted scrollbar track with a rounded thumb; the tab bar lays out whole
  tabs and shows edge arrows only in free space.
- DM bubbles drop the sender row and wrap to a second line, with the
  delivery mark and age closing it.
- Keyboards name what is typed (channel, contact, Password, Scope...)
  and show characters left near the limit; PIN entry has its own preview.
- Loading dots for waits (login, scan, GPS) and signal bars in place of
  raw RSSI in Nearby.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-01 22:59:44 +02:00
co-authored by Claude Opus 5.5
parent 1580de53f5
commit 608231ed6b
18 changed files with 877 additions and 500 deletions
@@ -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 <stdint.h>
#include <string.h>
#include <stdio.h>
#include <ctype.h>
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
+5 -121
View File
@@ -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() {
+12 -6
View File
@@ -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);
@@ -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 {
@@ -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;
}
@@ -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);
@@ -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);
@@ -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);
+151 -17
View File
@@ -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 <class GetH>
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;
+13 -5
View File
@@ -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);
});
}
+1 -1
View File
@@ -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);
@@ -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);
@@ -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);
@@ -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;
}
+52 -84
View File
@@ -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());
+108 -94
View File
@@ -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 = &lt;
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;
}
+322 -152
View File
@@ -1,13 +1,14 @@
#pragma once
#include <stdint.h>
#include <math.h>
#include <helpers/ui/DisplayDriver.h>
// ── 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.
+2 -1
View File
@@ -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);
}
}