Files
MeshCore-Solo/examples/companion_radio/ui-lvgl/Widgets.h
T
JakubandClaude Sonnet 5.5 9c40c83f4a fix(l2): the chat rows' preview isn't cut at the bottom
The font's line is 18 px (emoji and fallback glyphs), the subtitle label was a
fixed 15: the tails of y, g, j and emoji were cut. Now 18, the title and the
subtitle 1 px further apart; the row stays 44.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 21:33:59 +01:00

1351 lines
60 KiB
C++

#pragma once
// Included into UITask.cpp only, where it stands (it uses what is defined above it).
// The widget kit every screen builds from: label and text setters that skip
// needless redraws, popups, info cards, settings rows and pickers, the big clock.
// ── Small helpers ─────────────────────────────────────────────────────────────
// A plain box in `bg`. One in the page colour is left unpainted: the screen
// under it (or the popup panel) already is that colour, and filling it again
// costs a full-area blend per frame. The screen and the status bar, which
// something may scroll or slide under, are made opaque with styleOpaque().
static void styleSurface(lv_obj_t* o, uint32_t bg) {
lv_obj_set_style_bg_color(o, lv_color_hex(bg), 0);
lv_obj_set_style_bg_opa(o, bg == theme::BG ? LV_OPA_TRANSP : LV_OPA_COVER, 0);
lv_obj_set_style_border_width(o, 0, 0);
lv_obj_set_style_radius(o, 0, 0);
lv_obj_set_style_pad_all(o, 0, 0);
}
static void styleOpaque(lv_obj_t* o, uint32_t bg) {
styleSurface(o, bg);
lv_obj_set_style_bg_opa(o, LV_OPA_COVER, 0);
}
static bool growsWide(lv_obj_t* o) { // a flex grow along a row: the width is the layout's
lv_obj_t* p = lv_obj_get_parent(o);
if (!p || !lv_obj_get_style_flex_grow(o, LV_PART_MAIN) || lv_obj_get_style_layout(p, LV_PART_MAIN) != LV_LAYOUT_FLEX) return false;
lv_flex_flow_t f = lv_obj_get_style_flex_flow(p, LV_PART_MAIN);
return !(f & LV_FLEX_COLUMN);
}
// The width of `o`'s content, worked out from the styles -- or -1 where it
// hangs on its content or a row's flex grow. A new box's own size is only known
// after a layout pass, and LVGL lays out children before their parent, so
// each box sized in % of the one above adds a whole pass over the screen.
static int32_t innerW(lv_obj_t* o) {
if (!o || growsWide(o)) return -1;
int32_t w = lv_obj_get_style_width(o, LV_PART_MAIN);
if (LV_COORD_IS_PCT(w)) {
int32_t pw = innerW(lv_obj_get_parent(o));
if (pw < 0) return -1;
w = pw * LV_COORD_GET_PCT(w) / 100;
} else if (!LV_COORD_IS_PX(w)) {
return -1;
}
w -= lv_obj_get_style_pad_left(o, LV_PART_MAIN) + lv_obj_get_style_pad_right(o, LV_PART_MAIN);
lv_border_side_t side = lv_obj_get_style_border_side(o, LV_PART_MAIN);
int32_t bw = lv_obj_get_style_border_width(o, LV_PART_MAIN);
if (side & LV_BORDER_SIDE_LEFT) w -= bw;
if (side & LV_BORDER_SIDE_RIGHT) w -= bw;
return w;
}
extern "C" bool lv_obj_refr_size(lv_obj_t* obj); // lv_obj_pos.c, in no header
// A wrapping label's size settled now, `w` wide. LVGL's first layout of a
// label works out its height at the width it had before -- 0 for a new one,
// a line per letter -- and only then measures it again at the new width.
static void labelFit(lv_obj_t* l, int32_t w) {
l->coords.x2 = l->coords.x1 + w - 1;
lv_obj_refr_size(l);
}
// A flex row whose one growing child gets its width before the first layout
// (rowsFit(), further down).
static bool s_fit_pending;
static lv_obj_t* s_fit_screen; // newScreen()'s, maybe still fading in: not lv_screen_active() yet
static void fitLater(lv_obj_t* row) {
lv_obj_add_flag(row, LV_OBJ_FLAG_USER_2);
s_fit_pending = true;
}
// The parent's full width, in pixels when that's known now (see innerW()).
static void fillWidth(lv_obj_t* o) {
int32_t w = innerW(lv_obj_get_parent(o));
lv_obj_set_width(o, w >= 0 ? w : LV_PCT(100));
if (w > 0 && lv_obj_check_type(o, &lv_label_class)) labelFit(o, w);
}
static lv_obj_t* label(lv_obj_t* parent, const char* text, const lv_font_t* font, uint32_t color) {
lv_obj_t* l = lv_label_create(parent);
lv_label_set_text(l, text);
lv_obj_set_style_text_font(l, font, 0);
lv_obj_set_style_text_color(l, lv_color_hex(color), 0);
return l;
}
// Whether label `l` holds `text`. A LV_LABEL_LONG_DOT one that's cut short
// has the "..." written into its own copy of the text, the bytes they cover
// kept aside (lv_label_set_dots): it's read back past them.
static bool labelShows(lv_obj_t* l, const char* text) {
const lv_label_t* lb = (const lv_label_t*)l;
const char* cur = lb->text;
if (!cur) return false;
uint32_t d = lb->dot_begin;
if (d == 0xFFFFFFFF || lb->static_txt) return strcmp(cur, text) == 0; // no dots (lv_label.c LV_LABEL_DOT_BEGIN_INV)
if (strncmp(cur, text, d) != 0) return false;
size_t k = strnlen(lb->dot, sizeof(lb->dot)); // the bytes under the dots
if (strncmp(lb->dot, text + d, k) != 0) return false;
return k < sizeof(lb->dot) ? text[d + k] == '\0' : strcmp(cur + d + k, text + d + k) == 0;
}
// A label's text, set only when it differs: LVGL redraws a label on every
// set_text, the same text or not, and the refreshes that run every second
// mostly write back what's already there.
static void setText(lv_obj_t* l, const char* text) {
if (labelShows(l, text)) return;
lv_label_set_text(l, text);
}
// The same for a text colour: LVGL restyles, and redraws, on every set.
static void setTextColor(lv_obj_t* l, uint32_t color) {
lv_color_t c = lv_color_hex(color);
if (!lv_color_eq(lv_obj_get_style_text_color(l, LV_PART_MAIN), c)) lv_obj_set_style_text_color(l, c, 0);
}
static void setTextFmt(lv_obj_t* l, const char* fmt, ...) __attribute__((format(printf, 2, 3)));
static void setTextFmt(lv_obj_t* l, const char* fmt, ...) {
char b[160];
va_list ap;
va_start(ap, fmt);
vsnprintf(b, sizeof(b), fmt, ap);
va_end(ap);
setText(l, b);
}
// A sentence across the parent's width, wrapping: a hint, a note, a status.
static lv_obj_t* noteLabel(lv_obj_t* parent, const char* text, const lv_font_t* font = THEME_FONT_SMALL,
uint32_t color = theme::TEXT_MUTED) {
lv_obj_t* l = label(parent, text, font, color);
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
fillWidth(l);
return l;
}
// A list filling the rest of a flex column, scrolling on its own (the
// scrollbar only while it moves).
static lv_obj_t* scrollList(lv_obj_t* parent) {
lv_obj_t* l = lv_obj_create(parent);
styleSurface(l, theme::BG);
fillWidth(l);
lv_obj_set_flex_grow(l, 1);
lv_obj_set_flex_flow(l, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(l, theme::GAP, 0);
lv_obj_set_scrollbar_mode(l, LV_SCROLLBAR_MODE_ACTIVE);
return l;
}
// The dimmed full-screen layer under a popup; it swallows taps on what's below.
static lv_obj_t* dimOverlay(lv_obj_t* parent) {
lv_obj_t* o = lv_obj_create(parent);
lv_obj_remove_style_all(o);
lv_obj_set_size(o, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(o, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(o, LV_OPA_60, 0);
lv_obj_add_flag(o, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE);
return o;
}
// LV_EVENT_COVER_CHECK: an opaque thing LVGL can't tell is (an image, a box
// drawn by its children) hides all under its own area, and LVGL then starts
// drawing from it.
static void coversOwnArea(lv_event_t* e) {
lv_cover_check_info_t* info = (lv_cover_check_info_t*)lv_event_get_param(e);
lv_area_t a;
lv_obj_get_coords((lv_obj_t*)lv_event_get_current_target(e), &a);
const lv_area_t* r = info->area;
if (info->res != LV_COVER_RES_MASKED && r->x1 >= a.x1 && r->y1 >= a.y1 && r->x2 <= a.x2 && r->y2 <= a.y2)
info->res = LV_COVER_RES_COVER;
}
static lv_obj_t* s_status_bar = nullptr; // on the top layer, opaque
// Where the screen shows below the status bar (its top when the bar is hidden).
static int32_t belowStatusBar() {
return s_status_bar && !lv_obj_has_flag(s_status_bar, LV_OBJ_FLAG_HIDDEN) ? lv_obj_get_y2(s_status_bar) + 1 : 0;
}
// Where what drops in from the top (a toast, the message banner) goes: under
// the status bar, or under a screen's title bar, which takes its place.
static int32_t belowTopBar() {
return s_status_bar && lv_obj_has_flag(s_status_bar, LV_OBJ_FLAG_HIDDEN) ? theme::APPBAR_H : theme::STATUS_H;
}
// Home's tiles: `rows` of them fill the page between the status bar and the
// page dots, less `reserve` px left under them (a hint line).
static const int HOME_DOTS_H = 26;
static int homeTileH(int rows, int reserve = 0) {
const int page = lv_display_get_vertical_resolution(NULL) - theme::STATUS_H - theme::PAD - HOME_DOTS_H;
return (page - reserve - (rows - 1) * theme::GAP) / rows;
}
// What keeps the status bar up over a title bar (UITask::statusBarSync).
static bool s_sb_panel = false, s_sb_lock = false;
// The screen under an open popup, frozen: once the dim is up, the screen is
// drawn once into an image, darkened as the dim layer darkens it, and shown
// opaque on top of the screen with the dim layer cleared -- LVGL then draws
// nothing below it. Live, the screen plus a full-screen blend under the popup
// cost ~28 ms a frame. One popup at a time; the image goes with its popup, and
// the popup dims live again if its screen goes first. Any dim level: the
// layer's own when it's frozen.
namespace freeze {
static lv_obj_t* s_img = nullptr; // the frozen screen, on that screen
static lv_obj_t* s_owner = nullptr; // the popup's dim layer
static lv_obj_t* s_strip = nullptr; // the status bar's dim (a popup on the top layer)
static lv_timer_t* s_retry = nullptr; // waiting for the screen to stop moving
static lv_opa_t s_dim = LV_OPA_60; // the dim layer's, frozen into the image
static void imgDeleted(lv_event_t* e) {
lv_draw_buf_destroy((lv_draw_buf_t*)lv_event_get_user_data(e));
s_img = nullptr;
if (s_owner) { // the screen went, the popup stays
if (s_strip) lv_obj_delete(s_strip);
lv_obj_set_style_bg_opa(s_owner, s_dim, 0);
s_owner = s_strip = nullptr;
}
}
static void ownerDeleted(lv_event_t* e) {
if (s_retry && lv_timer_get_user_data(s_retry) == lv_event_get_target(e)) { lv_timer_delete(s_retry); s_retry = nullptr; }
if (s_owner != lv_event_get_target(e)) return;
s_owner = s_strip = nullptr;
if (s_img) lv_obj_delete(s_img);
}
static bool moving(lv_obj_t* scr) { // a screen still sliding / fading in
if (lv_display_get_screen_prev(NULL)) return true;
for (uint32_t i = 0; i < lv_obj_get_child_count(scr); i++)
if (lv_anim_get(lv_obj_get_child(scr, i), NULL)) return true;
return false;
}
static void take(lv_obj_t* overlay);
static void retry(lv_timer_t* t) {
s_retry = nullptr;
take((lv_obj_t*)lv_timer_get_user_data(t));
}
// Darkened into the display's own byte order (UI_FLUSH_DMA: big-endian;
// lv_snapshot can't make that one itself): drawn then as a plain copy.
#ifdef UI_FLUSH_DMA
static inline uint16_t out565(uint16_t c) { return __builtin_bswap16(c); }
#else
static inline uint16_t out565(uint16_t c) { return c; }
#endif
static void darken(lv_draw_buf_t* buf) { // as the dim layer over it would
const uint32_t keep = 256 - s_dim; // what's left under the black
for (uint32_t y = 0; y < buf->header.h; y++) {
uint16_t* p = (uint16_t*)(buf->data + y * buf->header.stride);
for (uint32_t x = 0; x < buf->header.w; x++) {
uint32_t c = p[x];
p[x] = out565((uint16_t)(((((c >> 11) * keep) >> 8) << 11) | (((((c >> 5) & 0x3F) * keep) >> 8) << 5) |
(((c & 0x1F) * keep) >> 8)));
}
}
#ifdef UI_FLUSH_DMA
buf->header.cf = LV_COLOR_FORMAT_RGB565_SWAPPED;
#endif
}
static lv_draw_buf_t* snap(lv_obj_t* overlay, lv_obj_t* scr, bool top) {
if (!top) lv_obj_add_flag(overlay, LV_OBJ_FLAG_HIDDEN);
lv_draw_buf_t* buf = lv_snapshot_take(scr, LV_COLOR_FORMAT_RGB565);
if (!top) lv_obj_remove_flag(overlay, LV_OBJ_FLAG_HIDDEN);
return buf;
}
static void show(lv_obj_t* overlay, lv_obj_t* scr, bool top, lv_draw_buf_t* buf) {
s_img = lv_image_create(scr);
lv_image_set_src(s_img, buf);
lv_obj_add_flag(s_img, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_remove_flag(s_img, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_pos(s_img, 0, 0);
lv_obj_add_event_cb(s_img, imgDeleted, LV_EVENT_DELETE, buf);
lv_obj_add_event_cb(s_img, coversOwnArea, LV_EVENT_COVER_CHECK, NULL); // no background: its own check says no
if (!top) lv_obj_move_foreground(overlay); // the image under the popup, over the rest
s_owner = overlay;
}
static void dimmed(lv_obj_t* overlay, bool top) { // the image holds the dim now
lv_obj_set_style_bg_opa(overlay, LV_OPA_TRANSP, 0);
if (top && belowStatusBar()) { // the status bar, on the top layer too, stays dimmed
s_strip = lv_obj_create(overlay);
lv_obj_remove_style_all(s_strip);
lv_obj_remove_flag(s_strip, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(s_strip, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_set_size(s_strip, LV_PCT(100), theme::STATUS_H);
lv_obj_set_style_bg_color(s_strip, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(s_strip, s_dim, 0);
}
}
static void take(lv_obj_t* overlay) {
if (s_img) return; // a popup over a popup: dimmed live
lv_obj_t* scr = lv_obj_get_screen(overlay);
bool top = scr == lv_layer_top();
if (top) scr = lv_screen_active();
if (moving(scr)) {
s_retry = lv_timer_create(retry, 60, overlay);
lv_timer_set_repeat_count(s_retry, 1);
return;
}
s_dim = lv_obj_get_style_bg_opa(overlay, LV_PART_MAIN);
lv_draw_buf_t* buf = snap(overlay, scr, top);
if (!buf) return; // no memory: dimmed live
darken(buf);
show(overlay, scr, top, buf);
dimmed(overlay, top);
}
static void onDimUp(lv_anim_t* a) { take((lv_obj_t*)a->var); }
// A popup opening over a still screen: frozen and dimmed at once, so the
// panel's rise redraws only the panel -- a dim fading up over the live
// screen redrew all of it every frame (~45 ms each). False when the screen
// is still moving or there's no memory: the caller fades the dim up.
static bool early(lv_obj_t* overlay, lv_opa_t dim) {
if (s_img) return false;
lv_obj_t* scr = lv_obj_get_screen(overlay);
bool top = scr == lv_layer_top();
if (top) scr = lv_screen_active();
if (moving(scr)) return false;
lv_draw_buf_t* buf = snap(overlay, scr, top);
if (!buf) return false;
s_dim = dim;
darken(buf);
show(overlay, scr, top, buf);
dimmed(overlay, top);
return true;
}
// A dim layer that's up at once, without the fade: frozen now.
static void now(lv_obj_t* overlay) {
lv_obj_add_event_cb(overlay, ownerDeleted, LV_EVENT_DELETE, NULL);
take(overlay);
}
}
// A popup: the dimmed layer (into `overlay`, to close it by) and its panel --
// the page colour, the accent hairline, a flex column -- rising into view.
// POP_FIT: as tall as its content, centred below the status bar, scrolling
// past that height. POP_FULL: all of that height; its own list scrolls.
// POP_TOP: as tall as its content, under the status bar, clear of a keyboard.
// POP_BOTTOM: as tall as its content, at the bottom, nothing dimmed -- for
// what's shown on the screen above it (a map area's preview).
enum PopFit : uint8_t { POP_FIT, POP_FULL, POP_TOP, POP_BOTTOM };
// The next popup is already up: back to it from a page it led to, it is
// there at once -- dimmed in full, no rise -- not opening again.
static bool s_pop_still = false;
static lv_obj_t* popupOpen(lv_obj_t* parent, PopFit fit, lv_obj_t*& overlay, int32_t inset = 8) {
overlay = dimOverlay(parent);
s_fit_pending = true; // rowsFit()
lv_obj_t* panel = lv_obj_create(overlay);
int32_t w = lv_display_get_horizontal_resolution(NULL), h = lv_display_get_vertical_resolution(NULL);
lv_obj_set_width(panel, w - 2 * inset);
if (fit == POP_FULL) {
lv_obj_set_height(panel, h - theme::STATUS_H - 12);
lv_obj_set_pos(panel, inset, theme::STATUS_H + 6);
} else if (fit == POP_TOP) {
lv_obj_set_height(panel, LV_SIZE_CONTENT);
lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4);
} else if (fit == POP_BOTTOM) {
lv_obj_set_height(panel, LV_SIZE_CONTENT);
lv_obj_align(panel, LV_ALIGN_BOTTOM_MID, 0, -inset);
} else {
lv_obj_set_height(panel, LV_SIZE_CONTENT);
lv_obj_set_style_max_height(panel, h - theme::STATUS_H - 12, 0);
lv_obj_align(panel, LV_ALIGN_CENTER, 0, theme::STATUS_H / 2);
lv_obj_set_scrollbar_mode(panel, LV_SCROLLBAR_MODE_ACTIVE);
}
if (fit != POP_FIT) lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_bg_color(panel, lv_color_hex(theme::BG), 0);
lv_obj_set_style_border_color(panel, lv_color_hex(theme::EDGE), 0);
lv_obj_set_style_border_width(panel, 1, 0);
lv_obj_set_style_radius(panel, theme::RADIUS, 0);
lv_obj_set_style_pad_all(panel, theme::PAD, 0);
lv_obj_set_style_pad_row(panel, 4, 0);
lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN);
const bool still = s_pop_still;
s_pop_still = false;
if (fit == POP_BOTTOM) { // over a live screen, nothing dimmed
lv_obj_set_style_bg_opa(overlay, LV_OPA_TRANSP, 0);
anim::rise(panel);
} else {
lv_obj_add_event_cb(overlay, freeze::ownerDeleted, LV_EVENT_DELETE, NULL);
if (still) { lv_obj_set_style_bg_opa(overlay, LV_OPA_60, 0); freeze::take(overlay); } // frozen as it looks
else if (freeze::early(overlay, LV_OPA_60)) anim::rise(panel);
else anim::popup(overlay, LV_OPA_60, freeze::onDimUp);
}
return panel;
}
// ── Info cards ──
// Label / value rows on a card (Diagnostics, GPS, node detail, About): the
// label small and muted on the left, the value on the right -- wrapping,
// right-aligned, when it's long -- and a hairline between rows. infoRow()
// returns the value label to update in place; infoSet() also hides its row
// while there's nothing to show.
static lv_obj_t* infoCard(lv_obj_t* parent) {
lv_obj_t* c = lv_obj_create(parent);
styleSurface(c, theme::SURFACE);
lv_obj_remove_flag(c, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_remove_flag(c, LV_OBJ_FLAG_CLICKABLE);
fillWidth(c);
lv_obj_set_height(c, LV_SIZE_CONTENT);
lv_obj_set_style_radius(c, theme::RADIUS, 0);
lv_obj_set_style_pad_hor(c, theme::PAD, 0);
lv_obj_set_flex_flow(c, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(c, 0, 0); // the rows' own padding spaces them, around the hairline
return c;
}
// A credit line that wraps cleanly: "(c) Name" and "CC-BY-SA" never split.
static const char* creditText(const char* in) {
static char out[160];
size_t o = 0;
int paren = 0;
for (const char* p = in; *p && o + 4 < sizeof(out); p++) {
if (*p == '(') paren++;
else if (*p == ')' && paren) paren--;
if ((uint8_t)p[0] == 0xC2 && (uint8_t)p[1] == 0xA9 && p[2] == ' ') { // "(c) " -> "(c)" + no-break space
memcpy(out + o, "\xC2\xA9\xC2\xA0", 4); o += 4; p += 2;
} else if (*p == '-' && paren) { // licence names: a non-breaking hyphen
memcpy(out + o, "\xE2\x80\x91", 3); o += 3;
} else {
out[o++] = *p;
}
}
out[o] = '\0';
return out;
}
static lv_obj_t* infoLine(lv_obj_t* card) { // one row's box, the hairline above all but the first
lv_obj_t* r = lv_obj_create(card);
lv_obj_remove_style_all(r);
lv_obj_remove_flag(r, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE);
fillWidth(r);
lv_obj_set_height(r, LV_SIZE_CONTENT);
lv_obj_set_style_pad_ver(r, 5, 0);
if (lv_obj_has_flag(card, LV_OBJ_FLAG_USER_1)) lv_obj_set_style_pad_hor(r, theme::PAD, 0); // in a group()
if (lv_obj_get_index(r) > 0) {
lv_obj_set_style_border_side(r, LV_BORDER_SIDE_TOP, 0);
lv_obj_set_style_border_width(r, 1, 0);
lv_obj_set_style_border_color(r, lv_color_hex(theme::SURFACE_2), 0);
}
return r;
}
static lv_obj_t* infoRow(lv_obj_t* card, const char* key, const char* value, uint32_t col = theme::TEXT,
const lv_font_t* font = THEME_FONT_BODY) {
lv_obj_t* r = infoLine(card);
lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(r, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(r, 8, 0);
lv_obj_t* k = label(r, key, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(k, LV_LABEL_LONG_WRAP);
lv_obj_set_width(k, 1);
lv_obj_set_flex_grow(k, 1);
lv_obj_t* v = label(r, value, font, col);
lv_label_set_long_mode(v, LV_LABEL_LONG_WRAP);
lv_obj_set_width(v, LV_SIZE_CONTENT);
lv_obj_set_style_max_width(v, LV_PCT(66), 0);
lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0);
fitLater(r);
return v;
}
// A row whose text is a sentence (credits, notes): the label over it.
static lv_obj_t* infoNote(lv_obj_t* card, const char* key, const char* text) {
lv_obj_t* r = infoLine(card);
lv_obj_set_flex_flow(r, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(r, 2, 0);
label(r, key, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_t* t = noteLabel(r, text, THEME_FONT_SMALL, theme::TEXT);
return t;
}
static lv_obj_t* sectionTitle(lv_obj_t* parent, const char* text);
static void infoSet(lv_obj_t* value, const char* text) {
lv_obj_t* row = lv_obj_get_parent(value);
if (!text || !text[0]) { lv_obj_add_flag(row, LV_OBJ_FLAG_HIDDEN); return; }
lv_obj_remove_flag(row, LV_OBJ_FLAG_HIDDEN);
setText(value, text);
}
// A bare flex row / column sized to its content, taps passing through.
static lv_obj_t* flexBox(lv_obj_t* parent, lv_flex_flow_t flow) {
lv_obj_t* b = lv_obj_create(parent);
lv_obj_remove_style_all(b);
lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(b, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(b, flow);
return b;
}
// A full-width row of centred items that grows with them (button rows in
// popups, the clock's tabs and actions).
static lv_obj_t* centerRow(lv_obj_t* parent) {
lv_obj_t* r = lv_obj_create(parent);
styleSurface(r, theme::BG);
lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(r, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(r, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(r, theme::GAP, 0);
return r;
}
// A small pill button with a label, right-aligned in its parent row.
static lv_obj_t* pillButton(lv_obj_t* row, const char* text, uint32_t bg, lv_event_cb_t cb, uintptr_t user) {
lv_obj_t* b = lv_button_create(row);
lv_obj_set_size(b, LV_SIZE_CONTENT, 24);
lv_obj_set_style_pad_hor(b, 10, 0);
lv_obj_set_style_pad_ver(b, 0, 0);
lv_obj_set_style_radius(b, theme::RADIUS, 0);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(bg), 0);
lv_obj_align(b, LV_ALIGN_RIGHT_MID, -2, 0);
lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, (void*)user);
lv_obj_center(label(b, text, THEME_FONT_SMALL, theme::TEXT));
return b;
}
// ── Settings kit ──
// Every settings page is built from the same few pieces: group() -- a section
// title and the card its rows sit on, hairlines between them -- and rows on
// it: settingRow() (label, hint under it, a control on the right: switch,
// choice, slider, segmented), listRow() (opens something: a chevron),
// actionRow() (does something now) and groupNote() (one line under a card).
// Rows built outside a group keep the older look: a card of their own.
static inline bool isGroup(lv_obj_t* o) { return o && lv_obj_has_flag(o, LV_OBJ_FLAG_USER_1); }
static lv_obj_t* group(lv_obj_t* parent, const char* title) {
if (title) sectionTitle(parent, title);
lv_obj_t* c = infoCard(parent);
lv_obj_set_style_pad_hor(c, 0, 0); // rows pad themselves: a pressed row lights edge to edge
lv_obj_add_flag(c, LV_OBJ_FLAG_USER_1);
return c;
}
// A row that lights up under the finger. In a group() the card clips its
// corners while a row is held, so the first / last row's light is rounded
// with it -- only then: clipping a card's corners costs a few ms every frame.
static void groupPressClip(lv_event_t* e) {
lv_obj_t* card = lv_obj_get_parent((lv_obj_t*)lv_event_get_current_target(e));
if (isGroup(card)) lv_obj_set_style_clip_corner(card, lv_event_get_code(e) == LV_EVENT_PRESSED, 0);
}
static void rowPressable(lv_obj_t* row) {
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_bg_color(row, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED);
lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_STATE_PRESSED);
lv_obj_add_event_cb(row, groupPressClip, LV_EVENT_PRESSED, NULL);
lv_obj_add_event_cb(row, groupPressClip, LV_EVENT_RELEASED, NULL);
lv_obj_add_event_cb(row, groupPressClip, LV_EVENT_PRESS_LOST, NULL);
}
static lv_obj_t* groupNote(lv_obj_t* parent, const char* text) {
lv_obj_t* l = noteLabel(parent, text);
lv_obj_set_style_pad_hor(l, theme::PAD, 0);
return l;
}
// A group row's box: full width, at least a touch target tall, a flex row
// with the text column growing and whatever follows it on the right.
static lv_obj_t* groupLine(lv_obj_t* card, bool tappable) {
lv_obj_t* r = infoLine(card);
lv_obj_set_style_pad_hor(r, theme::PAD, 0);
lv_obj_set_style_pad_ver(r, 6, 0);
lv_obj_set_style_min_height(r, theme::ROW_H, 0);
lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(r, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(r, 8, 0);
if (tappable) rowPressable(r);
return r;
}
// The label (and the hint under it, wrapping) filling a group row's left side.
static lv_obj_t* groupText(lv_obj_t* row, const char* text, const char* hint, uint32_t col = theme::TEXT) {
lv_obj_t* t = flexBox(row, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(t, 1);
lv_obj_set_flex_grow(t, 1);
lv_obj_set_style_pad_row(t, 1, 0);
// The row's touch-target height lives here: a row's own min height comes
// after its flex layout, which then sits everything at the top.
lv_obj_set_style_min_height(t, theme::ROW_H - 12, 0);
lv_obj_set_flex_align(t, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_t* l = noteLabel(t, text, THEME_FONT_BODY, col);
if (hint) {
noteLabel(t, hint);
}
lv_obj_set_user_data(row, l); // rowTitle()
fitLater(row);
return l;
}
// A group row's text column grows into what the controls on its right leave,
// and LVGL lays out children before their parent: its labels were first
// measured 1 px wide (a line per letter), then at the real width, and each
// height that changed took another pass up the screen -- four in all. Before
// a frame's layout, rowsFit() gives each new column the width the flex will:
// its row's minus the controls', worked out from their styles and text. The
// grow stays, so a control this can't size only costs the old passes.
static constexpr lv_obj_flag_t NOT_LAID_OUT =
(lv_obj_flag_t)(LV_OBJ_FLAG_HIDDEN | LV_OBJ_FLAG_FLOATING | LV_OBJ_FLAG_IGNORE_LAYOUT);
// `o`'s outer width as the layout will make it; -1 when that's unclear.
static int32_t layoutW(lv_obj_t* o) {
int32_t w = lv_obj_get_style_width(o, LV_PART_MAIN);
if (LV_COORD_IS_PX(w)) {
// as it is
} else if (w != LV_SIZE_CONTENT) {
return -1;
} else if (lv_obj_check_type(o, &lv_label_class)) {
lv_obj_refr_size(o); // the label's own measure, cached for the layout
w = lv_obj_get_width(o);
} else if (lv_obj_get_style_layout(o, LV_PART_MAIN) == LV_LAYOUT_FLEX &&
lv_obj_get_style_flex_flow(o, LV_PART_MAIN) == LV_FLEX_FLOW_ROW) {
int32_t gap = lv_obj_get_style_pad_column(o, LV_PART_MAIN), n = 0;
w = lv_obj_get_style_pad_left(o, LV_PART_MAIN) + lv_obj_get_style_pad_right(o, LV_PART_MAIN);
for (uint32_t i = 0; i < lv_obj_get_child_count(o); i++) {
lv_obj_t* c = lv_obj_get_child(o, i);
if (lv_obj_has_flag_any(c, NOT_LAID_OUT)) continue;
int32_t cw = layoutW(c);
if (cw < 0) return -1;
w += cw + (n++ ? gap : 0);
}
w = LV_CLAMP(lv_obj_get_style_min_width(o, LV_PART_MAIN), w, lv_obj_get_style_max_width(o, LV_PART_MAIN));
} else {
return -1;
}
return w + lv_obj_get_style_margin_left(o, LV_PART_MAIN) + lv_obj_get_style_margin_right(o, LV_PART_MAIN);
}
static void rowFit(lv_obj_t* row) {
lv_obj_refr_size(row); // its own width now: a child's % size is of it
int32_t w = innerW(row), gap = lv_obj_get_style_pad_column(row, LV_PART_MAIN);
if (w < 0) return;
lv_obj_t* grow = nullptr;
for (uint32_t i = 0; i < lv_obj_get_child_count(row); i++) {
lv_obj_t* c = lv_obj_get_child(row, i);
if (lv_obj_has_flag_any(c, NOT_LAID_OUT)) continue;
if (lv_obj_get_style_flex_grow(c, LV_PART_MAIN)) {
if (grow) return; // one grows, or the share isn't worth working out
grow = c;
continue;
}
int32_t cw = layoutW(c);
if (cw < 0) return;
w -= cw + gap;
}
if (!grow || w < 1) return;
lv_obj_set_width(grow, w);
if (lv_obj_check_type(grow, &lv_label_class)) { labelFit(grow, w); return; }
lv_obj_refr_size(grow);
for (uint32_t i = 0; i < lv_obj_get_child_count(grow); i++) { // its labels, 100 % of it
lv_obj_t* l = lv_obj_get_child(grow, i);
if (lv_obj_check_type(l, &lv_label_class)) labelFit(l, w);
}
}
// ... and every label given a width in pixels (not one that grows), which labelFit() then measures
// at it (the screen titles, a list row's cut-off name ...).
static void rowsFit(lv_obj_t* root) {
lv_obj_tree_walk(root, [](lv_obj_t* o, void*) {
if (lv_obj_has_flag(o, LV_OBJ_FLAG_USER_2)) {
lv_obj_remove_flag(o, LV_OBJ_FLAG_USER_2);
rowFit(o);
} else if (lv_obj_check_type(o, &lv_label_class) && !lv_obj_get_style_flex_grow(o, LV_PART_MAIN)) {
// (a growing one's px width is a stand-in: the flex's, or its row's rowFit())
int32_t w = lv_obj_get_style_width(o, LV_PART_MAIN);
if (LV_COORD_IS_PX(w) && w > 0 && lv_obj_get_width(o) != w) labelFit(o, w);
}
return LV_OBJ_TREE_WALK_NEXT;
}, nullptr);
}
static void rowsFitPending() {
if (!s_fit_pending) return;
s_fit_pending = false;
if (s_fit_screen && s_fit_screen != lv_screen_active()) rowsFit(s_fit_screen);
rowsFit(lv_screen_active());
rowsFit(lv_layer_top());
}
// lv_obj_update_layout() for a screen just built: its rows fitted first.
static void layoutNow(lv_obj_t* o) {
rowsFitPending();
lv_obj_update_layout(o);
}
// The title label of a settingRow() / listRow(), to recolour or rename it.
static lv_obj_t* rowTitle(lv_obj_t* row) { return (lv_obj_t*)lv_obj_get_user_data(row); }
// Rewrites the hint under a row's title in place (the label made right after
// it) -- a rebuild would cut short the animation of the switch just tapped.
static void rowHintSet(lv_obj_t* row, const char* hint) {
lv_obj_t* h = lv_obj_get_sibling(rowTitle(row), 1);
if (h && lv_obj_check_type(h, &lv_label_class)) lv_label_set_text(h, hint);
}
// A settings row: the label left (a muted hint under it, cut at `hint_w`),
// room on the right for a switch / dropdown / slider.
static lv_obj_t* settingRow(lv_obj_t* parent, const char* text, const char* hint, int hint_w = 150) {
if (isGroup(parent)) {
lv_obj_t* row = groupLine(parent, false);
groupText(row, text, hint);
return row;
}
lv_obj_t* row = lv_obj_create(parent);
styleSurface(row, theme::SURFACE);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(row, LV_PCT(100), theme::ROW_H);
lv_obj_set_style_radius(row, theme::RADIUS, 0);
lv_obj_t* t = label(row, text, THEME_FONT_BODY, theme::TEXT);
lv_obj_align(t, LV_ALIGN_TOP_LEFT, theme::PAD, hint ? 4 : 13);
lv_obj_set_user_data(row, t);
if (hint) {
lv_obj_t* h = label(row, hint, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(h, LV_LABEL_LONG_DOT);
lv_obj_set_size(h, hint_w, 18); // fixed height: cut, don't wrap onto the label -- the font's whole line, or the tails of y and g are cut
lv_obj_align(h, LV_ALIGN_BOTTOM_LEFT, theme::PAD, -4);
}
return row;
}
// A slider on the right of a settingRow(). Its track is the theme's 20 %
// accent tint worked out over the card, opaque: blended, it cost ~3 ms a frame.
static lv_obj_t* rowSlider(lv_obj_t* row, int32_t min, int32_t max, int32_t v) {
lv_obj_t* sl = lv_slider_create(row);
lv_slider_set_range(sl, min, max);
lv_slider_set_value(sl, v, LV_ANIM_OFF);
lv_obj_set_size(sl, 150, 8);
lv_obj_set_style_margin_right(sl, 8, 0);
lv_obj_set_ext_click_area(sl, 14);
lv_obj_set_style_bg_color(sl, lv_color_hex(theme::mix(theme::ACCENT, theme::SURFACE, 20)), LV_PART_MAIN);
lv_obj_set_style_bg_opa(sl, LV_OPA_COVER, LV_PART_MAIN);
return sl;
}
// Does something at once (Reboot, Delete ...): its text in `col`, no chevron.
// Returns the label, for a confirm-by-second-tap text.
static lv_obj_t* actionRow(lv_obj_t* card, const char* text, lv_event_cb_t cb, void* user,
uint32_t col = theme::TEXT) {
lv_obj_t* r = groupLine(card, true);
lv_obj_t* l = groupText(r, text, nullptr, col);
lv_obj_add_event_cb(r, cb, LV_EVENT_CLICKED, user);
return l;
}
// ── Choice: a value picked from a list ──
// In place of a dropdown: the value and a chevron; a tap opens the options
// over the screen, and a pick sends LV_EVENT_VALUE_CHANGED like a dropdown.
struct Choice {
char* opts; // "\n"-separated, owned
uint16_t sel, count;
char title[32];
lv_obj_t* value;
};
static lv_obj_t* s_pick_overlay = nullptr;
static lv_obj_t* s_pick_target = nullptr;
static Choice* choiceOf(lv_obj_t* c) { return (Choice*)lv_obj_get_user_data(c); }
// Option `i` of a "\n"-list, into buf.
static const char* choiceItem(const Choice* ch, int i, char* buf, size_t n) {
const char* p = ch->opts;
for (int k = 0; k < i && p; k++) { p = strchr(p, '\n'); if (p) p++; }
if (!p) { buf[0] = '\0'; return buf; }
const char* e = strchr(p, '\n');
size_t len = e ? (size_t)(e - p) : strlen(p);
if (len >= n) len = n - 1;
memcpy(buf, p, len);
buf[len] = '\0';
return buf;
}
static void choiceShow(lv_obj_t* c) {
Choice* ch = choiceOf(c);
char v[48];
lv_label_set_text(ch->value, choiceItem(ch, ch->sel, v, sizeof(v)));
}
static void choiceSetOptions(lv_obj_t* c, const char* opts) {
Choice* ch = choiceOf(c);
lv_free(ch->opts);
size_t n = strlen(opts);
ch->opts = (char*)lv_malloc(n + 1);
memcpy(ch->opts, opts, n + 1);
ch->count = 1;
for (const char* p = opts; *p; p++) if (*p == '\n') ch->count++;
if (ch->sel >= ch->count) ch->sel = 0;
choiceShow(c);
}
static int choiceSelected(lv_obj_t* c) { return choiceOf(c)->sel; }
static void choiceSetSelected(lv_obj_t* c, int i) {
Choice* ch = choiceOf(c);
if (i < 0 || i >= ch->count) return;
ch->sel = (uint16_t)i;
choiceShow(c);
}
static void pickerClose() {
if (s_pick_overlay) lv_obj_delete_async(s_pick_overlay);
s_pick_overlay = s_pick_target = nullptr;
}
static void onPickOption(lv_event_t* e) {
lv_obj_t* c = s_pick_target;
pickerClose();
if (!c) return;
choiceSetSelected(c, (int)(uintptr_t)lv_event_get_user_data(e));
lv_obj_send_event(c, LV_EVENT_VALUE_CHANGED, NULL); // may rebuild the screen: c is gone after this
}
static void pickerOpen(lv_obj_t* c) {
Choice* ch = choiceOf(c);
pickerClose();
s_pick_target = c;
lv_obj_t* panel = popupOpen(lv_layer_top(), POP_FIT, s_pick_overlay, 24);
lv_obj_add_event_cb(s_pick_overlay, [](lv_event_t* e) { // a tap beside the list: nothing changes
if (lv_event_get_target(e) == lv_event_get_current_target(e)) pickerClose();
}, LV_EVENT_CLICKED, NULL);
lv_obj_set_style_pad_all(panel, 6, 0);
lv_obj_t* t = label(panel, ch->title, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_set_style_pad_hor(t, 4, 0);
lv_obj_t* card = group(panel, nullptr);
lv_obj_t* sel_row = nullptr;
// Each option one label, a fixed one-line row: a time zone list is ~30 of
// them, and a group row's box + text column + label took ~3 ms each.
const int32_t H = 38, lh = lv_font_get_line_height(THEME_FONT_BODY), w = innerW(card);
for (int i = 0; i < ch->count; i++) {
char v[48];
bool cur = i == ch->sel;
lv_obj_t* r = label(card, choiceItem(ch, i, v, sizeof(v)), THEME_FONT_BODY, cur ? theme::ACCENT : theme::TEXT);
lv_label_set_long_mode(r, LV_LABEL_LONG_CLIP);
lv_obj_set_size(r, w > 0 ? w : LV_PCT(100), H);
lv_obj_set_style_pad_left(r, theme::PAD, 0);
lv_obj_set_style_pad_right(r, theme::PAD + 20, 0); // the tick's room
lv_obj_set_style_pad_top(r, (H - lh) / 2, 0);
if (i > 0) {
lv_obj_set_style_border_side(r, LV_BORDER_SIDE_TOP, 0);
lv_obj_set_style_border_width(r, 1, 0);
lv_obj_set_style_border_color(r, lv_color_hex(theme::SURFACE_2), 0);
}
rowPressable(r);
if (cur) {
lv_obj_t* t = label(r, LV_SYMBOL_OK, THEME_FONT_BODY, theme::ACCENT);
lv_obj_add_flag(t, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_align(t, LV_ALIGN_RIGHT_MID, 20, 0); // into the right padding
sel_row = r;
}
lv_obj_add_event_cb(r, onPickOption, LV_EVENT_CLICKED, (void*)(uintptr_t)i);
}
if (sel_row) { layoutNow(panel); lv_obj_scroll_to_view_recursive(sel_row, LV_ANIM_OFF); }
}
// A choice: in a group row the value is bare text (accent) and a chevron;
// elsewhere it sits on a small surface of its own.
static lv_obj_t* choiceCreate(lv_obj_t* parent, const char* opts, int sel, const char* title) {
lv_obj_t* c = lv_obj_create(parent);
lv_obj_remove_style_all(c);
lv_obj_add_flag(c, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(c, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(c, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(c, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(c, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); // END + SIZE_CONTENT mis-sizes
lv_obj_set_style_pad_column(c, 6, 0);
lv_obj_set_ext_click_area(c, 10);
lv_obj_set_style_opa(c, LV_OPA_60, LV_STATE_PRESSED);
if (!isGroup(lv_obj_get_parent(parent))) {
lv_obj_set_style_bg_color(c, lv_color_hex(theme::SURFACE_2), 0);
lv_obj_set_style_bg_opa(c, LV_OPA_COVER, 0);
lv_obj_set_style_radius(c, theme::RADIUS, 0);
lv_obj_set_style_pad_hor(c, 10, 0);
lv_obj_set_style_pad_ver(c, 8, 0);
}
Choice* ch = (Choice*)lv_malloc(sizeof(Choice));
memset(ch, 0, sizeof(*ch));
snprintf(ch->title, sizeof(ch->title), "%s", title ? title : "");
ch->value = label(c, "", THEME_FONT_BODY, theme::VALUE);
lv_obj_set_style_max_width(ch->value, 140, 0); // a long value wraps (LONG_DOT mis-sizes with SIZE_CONTENT)
lv_obj_set_style_text_align(ch->value, LV_TEXT_ALIGN_RIGHT, 0);
label(c, LV_SYMBOL_DOWN, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_set_user_data(c, ch);
ch->sel = sel < 0 ? 0 : (uint16_t)sel;
choiceSetOptions(c, opts);
lv_obj_add_event_cb(c, [](lv_event_t* e) { pickerOpen((lv_obj_t*)lv_event_get_current_target(e)); },
LV_EVENT_CLICKED, NULL);
lv_obj_add_event_cb(c, [](lv_event_t* e) {
lv_obj_t* o = (lv_obj_t*)lv_event_get_current_target(e);
if (s_pick_target == o) pickerClose();
Choice* ch = choiceOf(o);
lv_free(ch->opts);
lv_free(ch);
}, LV_EVENT_DELETE, NULL);
return c;
}
// A list's toolbar: one line over it, a muted text on the left (how many,
// what's missing) and its choices on the right (toolbarChoice). Returns the
// text, to update; its parent is the row.
static lv_obj_t* toolbarRow(lv_obj_t* parent) {
lv_obj_t* row = flexBox(parent, LV_FLEX_FLOW_ROW);
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(row, theme::GAP, 0);
lv_obj_t* t = label(row, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(t, LV_LABEL_LONG_DOT);
lv_obj_set_width(t, 1);
lv_obj_set_flex_grow(t, 1);
return t;
}
// A choice in a toolbarRow(): the value and a chevron on a small surface,
// the options over the screen as any choice's.
static lv_obj_t* toolbarChoice(lv_obj_t* row, const char* opts, int sel, const char* title) {
lv_obj_t* c = choiceCreate(row, opts, sel, title);
lv_obj_set_style_pad_ver(c, 6, 0);
lv_obj_set_style_pad_hor(c, 8, 0);
lv_obj_set_style_text_font(choiceOf(c)->value, THEME_FONT_SMALL, 0);
return c;
}
// One settings row: label left, a choice right.
static lv_obj_t* choiceRow(lv_obj_t* parent, const char* text, const char* hint, const char* options, int sel,
lv_event_cb_t cb, void* user = nullptr) {
lv_obj_t* row = settingRow(parent, text, hint);
lv_obj_t* c = choiceCreate(row, options, sel, text);
lv_obj_align(c, LV_ALIGN_RIGHT_MID, -4, 0);
lv_obj_add_event_cb(c, cb, LV_EVENT_VALUE_CHANGED, user);
return c;
}
// A value typed in a popup (frequency, name ...): shown as a choice is, with
// a pencil; the tap is the caller's.
static lv_obj_t* rowValue(lv_obj_t* row, const char* text, lv_event_cb_t cb, void* user = nullptr) {
lv_obj_t* c = flexBox(row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(c, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(c, 6, 0);
lv_obj_add_flag(c, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_ext_click_area(c, 10);
lv_obj_set_style_opa(c, LV_OPA_60, LV_STATE_PRESSED);
label(c, text, THEME_FONT_BODY, theme::VALUE);
label(c, LV_SYMBOL_EDIT, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_add_event_cb(c, cb, LV_EVENT_CLICKED, user);
return c;
}
// A tick on a listRow() (the one in use), left of its chevron.
static void rowCheck(lv_obj_t* row) {
lv_obj_t* l = label(row, LV_SYMBOL_OK, THEME_FONT_BODY, theme::ACCENT);
if (isGroup(lv_obj_get_parent(row))) lv_obj_move_to_index(l, (int32_t)lv_obj_get_child_count(row) - 2);
else lv_obj_align(l, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
}
// The subtitle label of a listRow() / settingRow() with one, to update it.
static lv_obj_t* rowSub(lv_obj_t* row) {
if (isGroup(lv_obj_get_parent(row))) return lv_obj_get_child(lv_obj_get_child(row, 0), 1);
return lv_obj_get_child(row, 1);
}
// Settings pages: the schema's, then this UI's own. Display, Power and Time
// split the schema's "Display & power" page (shared with L1, left as it is)
// into what their titles say.
enum : uint8_t { PG_KEYBOARD = settings::PG_COUNT, PG_ABOUT, PG_DISPLAY, PG_POWER, PG_TIME, PG_ALL };
// A schema setting by its NodePrefs field; -1 if there's none.
#define SETTING(field) settings::indexOf(offsetof(NodePrefs, field))
// A setting's current value as its choice shows it ("UTC+2", "3.4 V" ...).
static const char* settingText(const NodePrefs& p, int idx, char* buf, int n) {
buf[0] = '\0';
return idx < 0 ? buf : settings::text(p, settings::ALL[idx], buf, n);
}
static lv_obj_t* s_sec_card[settings::SEC_COUNT]; // schemaRows()' groups, by section
static bool s_opts_from_map = false; // Map options opened from the map: back returns there
static int s_nav_section = -1; // Map options: one section of them (from the map), -1 all
static int32_t s_settings_y = 0; // Settings' scroll, kept while a page of it is open
// A one-of-N choice: a row (or grid, with "\n" in the map) of checkable
// buttons, `sel` checked (-1: none). `item` is the unchecked button colour —
// SURFACE on a SURFACE_2 panel, SURFACE_2 on the page.
static lv_obj_t* segmented(lv_obj_t* parent, const char** map, int sel, int w, int h,
uint32_t item = theme::SURFACE_2) {
lv_obj_t* m = lv_buttonmatrix_create(parent);
lv_buttonmatrix_set_map(m, map);
lv_buttonmatrix_set_button_ctrl_all(m, LV_BUTTONMATRIX_CTRL_CHECKABLE);
lv_buttonmatrix_set_one_checked(m, true);
if (sel >= 0) lv_buttonmatrix_set_button_ctrl(m, sel, LV_BUTTONMATRIX_CTRL_CHECKED);
lv_obj_set_size(m, w, h);
lv_obj_set_style_pad_all(m, 0, 0);
lv_obj_set_style_pad_gap(m, 4, 0);
lv_obj_set_style_bg_opa(m, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(m, 0, 0);
lv_obj_set_style_bg_color(m, lv_color_hex(item), LV_PART_ITEMS);
lv_obj_set_style_bg_color(m, lv_color_hex(theme::SEL_BG), LV_PART_ITEMS | LV_STATE_CHECKED);
lv_obj_set_style_text_color(m, lv_color_hex(theme::TEXT), LV_PART_ITEMS);
lv_obj_set_style_text_color(m, lv_color_hex(theme::SEL_FG), LV_PART_ITEMS | LV_STATE_CHECKED);
lv_obj_set_style_text_font(m, THEME_FONT_SMALL, LV_PART_ITEMS);
lv_obj_set_style_shadow_width(m, 0, LV_PART_ITEMS);
lv_obj_set_style_radius(m, theme::RADIUS, LV_PART_ITEMS);
return m;
}
// A one-line text field, full width. The theme's padding leaves less than one
// line inside 34 px, which makes the field scroll vertically (text jumps as
// it's typed), so it's padded to fit exactly one body line: 34 = 2*1 border +
// 2*6 pad + 20. The theme draws the cursor only while FOCUSED.
static lv_obj_t* textField(lv_obj_t* parent, const char* placeholder = NULL) {
lv_obj_t* ta = lv_textarea_create(parent);
lv_textarea_set_one_line(ta, true);
if (placeholder) lv_textarea_set_placeholder_text(ta, placeholder);
lv_obj_set_size(ta, LV_PCT(100), 34);
lv_obj_set_style_border_width(ta, 1, 0);
lv_obj_set_style_pad_ver(ta, 6, 0);
lv_obj_set_style_pad_hor(ta, 10, 0);
lv_obj_set_scrollbar_mode(ta, LV_SCROLLBAR_MODE_OFF);
lv_obj_set_style_border_color(ta, lv_color_hex(theme::ACCENT), LV_PART_CURSOR | LV_STATE_FOCUSED);
lv_obj_set_style_border_width(ta, 2, LV_PART_CURSOR | LV_STATE_FOCUSED);
return ta;
}
// A field's cursor shown (FOCUSED, blinking) or not. LVGL starts and stops
// the blink only as the cursor moves, so it's nudged here: left running on a
// field without focus, it redraws the field every 400 ms for nothing.
// (lv_keyboard_set_textarea() focuses the field it's given.)
static void fieldFocus(lv_obj_t* ta, bool on) {
if (!ta) return;
if (on) lv_obj_add_state(ta, LV_STATE_FOCUSED);
else lv_obj_remove_state(ta, LV_STATE_FOCUSED);
lv_textarea_set_cursor_pos(ta, lv_textarea_get_cursor_pos(ta));
}
// A field limited in UTF-8 bytes (user data: the cap) -- names and other text
// that is stored in a fixed buffer. LVGL's own limit counts characters.
static void onByteCapInsert(lv_event_t* e) {
lv_obj_t* ta = (lv_obj_t*)lv_event_get_target(e);
const char* ins = (const char*)lv_event_get_param(e);
size_t cap = (size_t)(uintptr_t)lv_event_get_user_data(e);
if (ins && strlen(lv_textarea_get_text(ta)) + strlen(ins) > cap) lv_textarea_set_insert_replace(ta, "");
}
// The primary action of a screen or popup: full accent, ON_ACCENT text (Theme.h);
// call after its label is made.
static void stylePrimary(lv_obj_t* b) {
lv_obj_set_style_bg_color(b, lv_color_hex(theme::ACCENT), 0);
for (uint32_t i = 0; i < lv_obj_get_child_count(b); i++)
lv_obj_set_style_text_color(lv_obj_get_child(b, i), lv_color_hex(theme::ON_ACCENT), 0);
}
// Something selected / on (a tab, a chip, a row): SEL_BG with SEL_FG text
// (Theme.h), or back to the surface with TEXT. Every label under it follows.
static void textUnder(lv_obj_t* o, uint32_t col) {
for (uint32_t i = 0; i < lv_obj_get_child_count(o); i++) {
lv_obj_t* c = lv_obj_get_child(o, i);
if (lv_obj_check_type(c, &lv_label_class)) setTextColor(c, col);
else textUnder(c, col);
}
}
static void styleSelected(lv_obj_t* b, bool on) {
lv_obj_set_style_bg_color(b, lv_color_hex(on ? theme::SEL_BG : theme::SURFACE), 0);
lv_obj_set_style_bg_color(b, lv_color_hex(on ? theme::SEL_PRESS : theme::SURFACE_2), LV_STATE_PRESSED);
textUnder(b, on ? theme::SEL_FG : theme::TEXT);
}
// A row of equal buttons at the foot of a popup or screen: buttonBar(), then
// barButton() for each -- `accent` for the main action, in the full accent
// (barButtonOn() flips it later; a button that is on, as Fav, takes
// styleSelected() instead). Returns the button; its label is child 0.
static lv_obj_t* buttonBar(lv_obj_t* parent) {
lv_obj_t* bar = lv_obj_create(parent);
styleSurface(bar, theme::BG);
lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(bar, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW);
lv_obj_set_style_pad_column(bar, theme::GAP, 0);
return bar;
}
static void barButtonOn(lv_obj_t* b, bool on) {
lv_obj_set_style_bg_color(b, lv_color_hex(on ? theme::ACCENT : theme::SURFACE), 0);
lv_obj_set_style_bg_color(b, lv_color_hex(on ? theme::mix(theme::ACCENT, theme::BG, 75) : theme::SURFACE_2), LV_STATE_PRESSED);
textUnder(b, on ? theme::ON_ACCENT : theme::TEXT);
}
static lv_obj_t* barButton(lv_obj_t* bar, const char* text, lv_event_cb_t cb, uintptr_t user, bool accent = false) {
lv_obj_t* b = lv_button_create(bar);
lv_obj_set_height(b, 40);
lv_obj_set_flex_grow(b, 1);
lv_obj_set_style_pad_hor(b, 4, 0);
lv_obj_set_style_radius(b, theme::RADIUS, 0);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, (void*)user);
lv_obj_center(label(b, text, THEME_FONT_SMALL, theme::TEXT));
barButtonOn(b, accent);
return b;
}
// Asking before something that can't be undone: under navPopupPanel("...?"),
// what happens in a sentence and the red button that does it.
static lv_obj_t* confirmBody(lv_obj_t* panel, const char* text, const char* button, lv_event_cb_t cb, uintptr_t user) {
noteLabel(panel, text);
lv_obj_t* b = lv_button_create(panel);
lv_obj_set_size(b, LV_PCT(100), 40);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_radius(b, theme::RADIUS, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(theme::FAIL), 0);
lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, (void*)user);
lv_obj_center(label(b, button, THEME_FONT_BODY, theme::TEXT));
return b;
}
// A button that asks by itself before it destroys: the first tap puts `ask`
// on its label, a second tap within 3 s is the yes. Left alone, the label
// comes back and the next tap asks again. One button asks at a time.
static lv_obj_t* s_ask_lbl = nullptr;
static char s_ask_text[64];
static lv_timer_t* s_ask_timer = nullptr;
static void onAskDeleted(lv_event_t* e);
static void askReset() {
if (s_ask_timer) { lv_timer_delete(s_ask_timer); s_ask_timer = nullptr; }
if (!s_ask_lbl) return;
lv_obj_remove_event_cb(s_ask_lbl, onAskDeleted);
lv_label_set_text(s_ask_lbl, s_ask_text);
s_ask_lbl = nullptr;
}
static void onAskDeleted(lv_event_t* e) { (void)e; s_ask_lbl = nullptr; askReset(); }
static bool tapConfirmed(lv_obj_t* lbl, const char* ask) {
bool yes = lbl && lbl == s_ask_lbl;
askReset();
if (yes || !lbl) return yes;
s_ask_lbl = lbl;
snprintf(s_ask_text, sizeof(s_ask_text), "%s", lv_label_get_text(lbl));
lv_label_set_text(lbl, ask);
lv_obj_add_event_cb(lbl, onAskDeleted, LV_EVENT_DELETE, NULL);
s_ask_timer = lv_timer_create([](lv_timer_t*) { s_ask_timer = nullptr; askReset(); }, 3000, NULL);
lv_timer_set_repeat_count(s_ask_timer, 1);
return false;
}
// "@[nick]" -> "@nick" in place, for one-line text (previews, quotes) where
// the bubble's highlight doesn't reach.
static void plainMentions(char* t) {
char* w = t;
for (const char* r = t; *r;) {
const char* close = r[0] == '@' && r[1] == '[' ? strchr(r + 2, ']') : nullptr;
if (close && close - r <= 34) {
*w++ = '@';
for (const char* q = r + 2; q < close;) *w++ = *q++;
r = close + 1;
} else {
*w++ = *r++;
}
}
*w = '\0';
}
// Unread badge: amber pill with a count, on the right edge of `parent`.
static void badge(lv_obj_t* parent, int n, bool overflow) {
if (n <= 0) return;
lv_obj_t* b = lv_obj_create(parent);
lv_obj_remove_flag(b, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(b, LV_SIZE_CONTENT, 20);
lv_obj_set_style_bg_color(b, lv_color_hex(theme::ACCENT), 0);
lv_obj_set_style_border_width(b, 0, 0);
lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_pad_hor(b, 7, 0);
lv_obj_set_style_pad_ver(b, 0, 0);
lv_obj_align(b, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
lv_obj_t* l = label(b, "", THEME_FONT_SMALL, theme::ON_ACCENT);
lv_label_set_text_fmt(l, "%d%s", n, overflow ? "+" : "");
lv_obj_center(l);
}
// A vlist row's badge (its third child): made the first time there's a
// count, then its count set, hidden at none.
static void rowBadge(lv_obj_t* row, int n, bool overflow) {
lv_obj_t* b = lv_obj_get_child(row, 2);
if (!b) { badge(row, n, overflow); return; }
lv_obj_set_flag(b, LV_OBJ_FLAG_HIDDEN, n <= 0);
if (n <= 0) return;
char t[8];
snprintf(t, sizeof(t), "%d%s", n, overflow ? "+" : "");
setText(lv_obj_get_child(b, 0), t);
}
// The same on a Home tile: in its top-right corner, clear of the icon.
static void tileBadge(lv_obj_t* tile, int n) {
if (n <= 0) return;
badge(tile, n, false);
lv_obj_align(lv_obj_get_child(tile, -1), LV_ALIGN_TOP_RIGHT, -2, 2);
}
// Local time (NodePrefs::tz_offset_hours), now or at `utc`; false before the clock is set.
static bool localTime(const NodePrefs* p, struct tm& out, uint32_t utc = rtc_clock.getCurrentTime()) {
return localTm(utc, p ? p->tz_offset_hours : 0, out);
}
static const char* const MONTHS[] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
// "14:05", or "2:05" (+ " PM" with suffix) with Settings > 12-hour clock;
// with `seconds`, ":09" follows unless Settings > Time > Clock seconds is off.
static void fmtClock(char* b, size_t n, const struct tm& ti, const NodePrefs* p, bool suffix, bool seconds = false) {
char sec[4] = "";
if (seconds && p && !p->clock_hide_seconds) snprintf(sec, sizeof(sec), ":%02d", ti.tm_sec);
if (p && p->clock_12h) {
int h = ti.tm_hour % 12;
snprintf(b, n, "%d:%02d%s%s", h ? h : 12, ti.tm_min, sec, suffix ? (ti.tm_hour < 12 ? " AM" : " PM") : "");
} else {
snprintf(b, n, "%02d:%02d%s", ti.tm_hour, ti.tm_min, sec);
}
}
// "Thu 25 Sep 2026".
static void fmtDate(char* b, size_t n, const struct tm& ti) {
static const char* DOW[] = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" };
snprintf(b, n, "%s %d %s %d", DOW[ti.tm_wday], ti.tm_mday, MONTHS[ti.tm_mon], ti.tm_year + 1900);
}
// Settings changed here reach flash once their screen is left (as on the L1:
// a run of switches is one write, and the flash wears less), just after the
// next screen has come in -- a save stalls the UI (0.1-0.6 s on the ESP32's
// SPIFFS, far less on LittleFS). Else when the screen goes off, or a minute on. NodePrefs
// itself changes at once; shutdown() saves whatever is pending.
static uint32_t s_prefs_dirty_ms = 0; // when the first unsaved change came (0: none)
static uint32_t s_prefs_save_at = 0; // the screen was left: save from then (0: not yet)
static void prefsSave() { if (!s_prefs_dirty_ms) s_prefs_dirty_ms = millis() | 1; }
static void prefsSaveSoon(uint32_t delay_ms) {
if (s_prefs_dirty_ms && !s_prefs_save_at) s_prefs_save_at = (millis() + delay_ms) | 1;
}
static void prefsFlush() {
if (!s_prefs_dirty_ms) return;
bool due = s_prefs_save_at ? (int32_t)(millis() - s_prefs_save_at) >= 0 : millis() - s_prefs_dirty_ms >= 60000;
if (!due) return;
s_prefs_dirty_ms = s_prefs_save_at = 0;
the_mesh.savePrefs();
}
// The big clock in the boot splash's lettering, as ui-new draws it on the
// OLED and e-ink (ui-core/Lettering.h: block digits in 3 px strokes): HH:MM
// at SC x, the seconds after them at SC_SEC x on the same baseline. The
// lettering leans a glyph row at a time, steps SC px tall at this size;
// here the lean is worked out per screen row, so it's a smooth slant.
#include "../ui-core/Lettering.h"
namespace bigclock {
static const int SC = 4, SC_SEC = 2;
struct Text { char hm[8]; char sec[4]; };
static int lean(int py, int h, int sc) { return (2 * sc * (h - 1 - py) + (h - 1) / 2) / (h - 1); } // 2 glyph px at the top, 0 at the bottom
static int32_t width(const char* t, int sc) {
const int w = lettering::textW(t, 0);
return w <= 0 ? 0 : w * sc + ((int)strlen(t) - 1) * (sc + 1);
}
static void draw(lv_layer_t* layer, int32_t x, int32_t y, const char* t, int sc, lv_color_t col) {
const int h = lettering::LOGO_H * sc;
lv_draw_rect_dsc_t d;
lv_draw_rect_dsc_init(&d);
d.bg_color = col;
for (const char* p = t; *p; p++) {
const lettering::PixGlyph* g = lettering::pixGlyph(*p);
if (!g) continue;
const int gw = (int)strlen(g->rows[0]);
for (int py = 0; py < h; ) {
const int r = py / sc, off = lean(py, h, sc);
int end = py + 1; // the rows under it on the same glyph row, leaning as far
while (end < h && end / sc == r && lean(end, h, sc) == off) end++;
const char* row = g->rows[r];
for (int c = 0; c < gw; c++) { // runs of ink as one rect
if (row[c] != '#') continue;
int e = c;
while (e + 1 < gw && row[e + 1] == '#') e++;
lv_area_t a = { x + off + c * sc, y + py, x + off + (e + 1) * sc - 1, y + end - 1 };
lv_draw_rect(layer, &d, &a);
c = e;
}
py = end;
}
x += (gw + 2) * sc + sc + 1;
}
}
static void onDraw(lv_event_t* e) {
lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e);
const Text* t = (const Text*)lv_obj_get_user_data(o);
if (!t) return;
lv_area_t a;
lv_obj_get_coords(o, &a);
const lv_color_t col = lv_obj_get_style_text_color(o, LV_PART_MAIN);
draw(lv_event_get_layer(e), a.x1, a.y1, t->hm, SC, col);
if (t->sec[0])
draw(lv_event_get_layer(e), a.x1 + width(t->hm, SC) + 3 * SC, a.y2 + 1 - lettering::LOGO_H * SC_SEC, t->sec, SC_SEC, col);
}
static void onDelete(lv_event_t* e) { lv_free(lv_obj_get_user_data((lv_obj_t*)lv_event_get_target(e))); }
static lv_obj_t* create(lv_obj_t* parent) {
lv_obj_t* o = lv_obj_create(parent);
lv_obj_remove_style_all(o);
lv_obj_remove_flag(o, LV_OBJ_FLAG_CLICKABLE);
// Graphite on the light theme: that much black, that big, was heavy.
lv_obj_set_style_text_color(o, lv_color_hex(theme::s_light ? theme::mix(theme::TEXT, theme::BG, 80) : theme::TEXT), 0);
Text* t = (Text*)lv_malloc(sizeof(Text));
if (t) { snprintf(t->hm, sizeof(t->hm), "--:--"); t->sec[0] = 0; }
lv_obj_set_user_data(o, t);
lv_obj_set_size(o, width("--:--", SC), lettering::LOGO_H * SC);
lv_obj_add_event_cb(o, onDraw, LV_EVENT_DRAW_MAIN_END, NULL);
lv_obj_add_event_cb(o, onDelete, LV_EVENT_DELETE, NULL);
return o;
}
static void set(lv_obj_t* o, const char* hm, const char* sec) {
Text* t = (Text*)lv_obj_get_user_data(o);
if (!t || (!strcmp(t->hm, hm) && !strcmp(t->sec, sec))) return;
snprintf(t->hm, sizeof(t->hm), "%s", hm);
snprintf(t->sec, sizeof(t->sec), "%s", sec);
const int32_t w = width(hm, SC) + (sec[0] ? 3 * SC + width(sec, SC_SEC) : 0);
if (w != lv_obj_get_width(o)) lv_obj_set_width(o, w);
lv_obj_invalidate(o);
}
} // namespace bigclock
// A big clock (Home, the lock screen): the digits and, on a 12-hour clock,
// AM / PM beside them in `small`, on the digits' baseline. `big` null: the
// digits in the lettering (bigclock above). clockFaceSet() once a second;
// "--:--" until the time is known.
static lv_obj_t* clockFace(lv_obj_t* parent, const lv_font_t* big, const lv_font_t* small) {
lv_obj_t* f = flexBox(parent, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(f, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_END);
lv_obj_set_style_pad_column(f, big ? 4 : 3 * bigclock::SC, 0);
if (big) label(f, "--:--", big, theme::TEXT);
else { bigclock::create(f); lv_obj_set_style_pad_bottom(f, 6, 0); } // clear of the line under it
lv_obj_t* ap = label(f, "", small, theme::TEXT_MUTED);
if (big) lv_obj_set_style_pad_bottom(ap, big->base_line - small->base_line, 0);
else lv_obj_set_style_translate_y(ap, small->base_line, 0); // its baseline on the digits' foot
lv_obj_add_flag(ap, LV_OBJ_FLAG_HIDDEN);
return f;
}
static void clockFaceSet(lv_obj_t* f, const struct tm* ti, const NodePrefs* p) {
lv_obj_t* digits = lv_obj_get_child(f, 0);
lv_obj_t* ap = lv_obj_get_child(f, 1);
if (lv_obj_check_type(digits, &lv_label_class)) {
char clk[12] = "--:--";
if (ti) fmtClock(clk, sizeof(clk), *ti, p, false, true);
setText(digits, clk);
} else {
char hm[8] = "--:--", sec[4] = "";
if (ti) {
if (p && p->clock_12h) snprintf(hm, sizeof(hm), "%d:%02d", ti->tm_hour % 12 ? ti->tm_hour % 12 : 12, ti->tm_min);
else snprintf(hm, sizeof(hm), "%02d:%02d", ti->tm_hour, ti->tm_min);
if (!p || !p->clock_hide_seconds) snprintf(sec, sizeof(sec), "%02d", ti->tm_sec);
}
bigclock::set(digits, hm, sec);
}
bool h12 = ti && p && p->clock_12h;
if (h12) setText(ap, ti->tm_hour < 12 ? "AM" : "PM");
if (h12 != !lv_obj_has_flag(ap, LV_OBJ_FLAG_HIDDEN)) {
if (h12) lv_obj_remove_flag(ap, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(ap, LV_OBJ_FLAG_HIDDEN);
}
}
// A message's age ("12s" / "5m" / "3h" / "2d"). After a restart the clock runs
// from the build date until GPS or the app sets it, so messages restored from
// the card look newer than "now": those show when they came ("25 Sep 14:05").
static const NodePrefs* s_prefs = nullptr; // for the free helpers below; set in begin()
static bool clockBehind(uint32_t now, uint32_t ts) { return ts > 1000000000UL && ts > now + 120; }
static void fmtMsgAge(char* b, size_t n, uint32_t now, uint32_t ts, const NodePrefs* p) {
if (!clockBehind(now, ts)) { geo::fmtAgeShort(b, (int)n, now, ts ? ts : now); return; }
struct tm ti;
localTime(p, ti, ts); // set: clockBehind() says ts is
char clk[12];
fmtClock(clk, sizeof(clk), ti, p, true);
snprintf(b, n, "%d %s %s", ti.tm_mday, MONTHS[ti.tm_mon], clk);
}
static void contactName(const uint8_t* prefix, char* out, size_t n) {
ContactInfo c;
if (MessageHistory::contactByPrefix(prefix, c) && c.name[0]) snprintf(out, n, "%s", c.name);
else snprintf(out, n, "%02X%02X%02X%02X", prefix[0], prefix[1], prefix[2], prefix[3]);
}