#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; } // A row title led by a symbol (" Text"): the symbol gets a column of // its own, the same width on every row, so the titles and hints under them // line up whatever the glyph's width. Returns the text after it, or nullptr. static const int ICON_COL_W = 22; static const char* rowIconSplit(const char* text, char* icon) { const uint8_t* u = (const uint8_t*)text; if (!u || u[0] != 0xEF || !u[1] || !u[2] || u[3] != ' ' || u[4] != ' ') return nullptr; // U+F000.. symbols memcpy(icon, text, 3); icon[3] = '\0'; return text + 5; } // 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) { char icon[4]; if (const char* rest = rowIconSplit(text, icon)) { lv_obj_t* i = label(row, icon, THEME_FONT_BODY, col); lv_obj_set_width(i, ICON_COL_W); lv_obj_set_style_text_align(i, LV_TEXT_ALIGN_CENTER, 0); text = rest; } 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 picked or typed behind the row: cut to `w`, with a chevron, the whole // row is the tap. Returns the value's label. // rowPick()'s value: as wide as its text, up to the width it was given (kept // as its max width), so a short value leaves the hint beside it room. static void pickSet(lv_obj_t* v, const char* text) { lv_label_set_text(v, text); const lv_font_t* f = lv_obj_get_style_text_font(v, LV_PART_MAIN); lv_point_t sz; lv_text_get_size(&sz, text, f, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); int32_t w = sz.x + 1; int32_t max = lv_obj_get_style_max_width(v, LV_PART_MAIN); lv_obj_set_width(v, w < max ? w : max); // a px width: LONG_DOT needs one } static lv_obj_t* rowPick(lv_obj_t* row, const char* text, int w, lv_event_cb_t cb, void* user = nullptr) { lv_obj_t* v = label(row, "", THEME_FONT_BODY, theme::ACCENT); lv_label_set_long_mode(v, LV_LABEL_LONG_DOT); lv_obj_set_style_max_width(v, w, 0); lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0); pickSet(v, text); label(row, LV_SYMBOL_RIGHT, THEME_FONT_SMALL, theme::TEXT_MUTED); rowPressable(row); lv_obj_add_event_cb(row, cb, LV_EVENT_CLICKED, user); return v; } // 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. // Picked on release over the same button (CLICK_TRIG), not on press as // lv_buttonmatrix does by default: a swipe starting on a screen's tabs (to // scroll the list under them back up) switched the tab. 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_t)(LV_BUTTONMATRIX_CTRL_CHECKABLE | LV_BUTTONMATRIX_CTRL_CLICK_TRIG)); 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); } // The one main action under a form or a list, full width (Save, Measure ...). // Its label is child 0. static lv_obj_t* primaryButton(lv_obj_t* parent, const char* text, lv_event_cb_t cb, void* user = nullptr) { lv_obj_t* b = lv_button_create(parent); 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_add_event_cb(b, cb, LV_EVENT_CLICKED, user); lv_obj_center(label(b, text, THEME_FONT_BODY, theme::TEXT)); stylePrimary(b); return b; } // 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 (time zone + summer time), 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, tzHoursAt(p, utc), 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) | 1; } // never 0, never ahead of millis() 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(); } // A switch, one size on every screen. static lv_obj_t* switchCreate(lv_obj_t* parent, bool on, lv_event_cb_t cb, void* user) { lv_obj_t* sw = lv_switch_create(parent); lv_obj_set_size(sw, 46, 24); if (on) lv_obj_add_state(sw, LV_STATE_CHECKED); if (cb) lv_obj_add_event_cb(sw, cb, LV_EVENT_VALUE_CHANGED, user); return sw; } // A preference toggle (0/1 byte in NodePrefs), saved on change. static void onPrefSwitch(lv_event_t* e) { uint8_t* pref = (uint8_t*)lv_event_get_user_data(e); *pref = lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED) ? 1 : 0; prefsSave(); } // A settings row with a switch on the right. pref == nullptr: the caller wires // the switch itself. static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref) { lv_obj_t* row = settingRow(parent, text, sub, 236); // the hint clear of the switch lv_obj_t* sw = switchCreate(row, pref && *pref, pref ? onPrefSwitch : nullptr, pref); lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); return sw; } // The same for a switch that isn't a plain pref: `cb` gets `user` on a change. static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, bool on, lv_event_cb_t cb, void* user = nullptr) { lv_obj_t* sw = switchRow(parent, text, sub, nullptr); if (on) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, cb, LV_EVENT_VALUE_CHANGED, user); return sw; } // A switch row whose text opens the details (`open`) while the switch only // switches: Bluetooth, WiFi, GPS. static void onSwitchRowTap(lv_event_t* e) { if (lv_event_get_target(e) != lv_event_get_current_target(e)) return; // the switch ((lv_event_cb_t)lv_event_get_user_data(e))(e); } static void switchRowOpens(lv_obj_t* sw, lv_event_cb_t open) { lv_obj_t* row = lv_obj_get_parent(sw); rowPressable(row); lv_obj_add_event_cb(row, onSwitchRowTap, LV_EVENT_CLICKED, (void*)open); } // 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]); }