feat(ui-lvgl): roadmap stage 6 - light motion, unified style, accent picker, splash

- Anim.h: screens emerge from the middle (background cover fades, content
  drifts 6 px), Home pages slide, popups and toasts rise and fade in
- Theme.h: accent rules (fill = primary action, dim fill = selected, text =
  names/counts/modes), theme layer over the default theme, radius tokens,
  accent colour picker (Settings > Display & power > LOOK, NVS mc_ui)
- Splash with the Solo and upstream versions
- Status icons in equal cells, L1-style relay marks, path window with a hop
  diagram, tile download popup fits the screen (WiFi only in Settings)
- Perf: uncompressed fonts, two 120-line PSRAM buffers (~48 -> ~33 ms per
  transition frame); UI_PERF_TEST harness

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-26 13:06:22 +02:00
co-authored by Claude Opus 5.5
parent 5124587b26
commit 3fd204489e
27 changed files with 45643 additions and 36636 deletions
+13
View File
@@ -277,3 +277,16 @@ The speaker plays through the ES8311 codec (I2S MCLK 10 / BCK 11 / WS 12 / DOUT
- [ ] After "restarting..." the device boots the new version (Settings shows it); contacts, messages, settings, PIN and WiFi kept
- [ ] Up to date → "Up to date - the latest release is vX"; WiFi off / no network / map download running → toasts instead
- [ ] Wrong network in the middle (unplug router) → "Download stalled", old firmware still boots
## Roadmap stage 6: motion and look (2026-09-26)
- [ ] Boot: splash (amber MESHCORE, SOLO, Solo version, "MeshCore x - built date", three dots rising in turn) for ~2.5 s, then fades into Home (or the PIN card); a tap skips it
- [ ] Changing screens: the new one fades in from the background with its content drifting into place (up going in, down coming back); rebuilding the same screen (settings toggles, a new message) doesn't animate
- [ ] Selected things look the same everywhere (dim accent + light text): Nearby chips, Clock / Bot / Diagnostics tabs, Repeater segments; primary actions are full accent with dark text (map Download / Resume, Firmware update, WiFi / channel Save, Clock Start, alarm Dismiss, New message)
- [ ] Settings > Display & power > LOOK > Accent colour: tap a swatch → the page, status icons, chips, names recolour at once; other screens in the new colour; kept after a reboot (splash too)
- [ ] Home page swipe / dot tap: the tile row slides in from the swipe side
- [ ] Popups (map tools, Nearby advert, scan, radio frequency, map download): backdrop fades, panel rises; toasts rise in and fade out
- [ ] Buttons shrink slightly while pressed
- [ ] Status bar icons evenly spaced, muted shows a speaker with a cross (also next to muted conversations)
- [ ] Own channel post: "1s ✓ 2" (green, number of repeaters heard relaying it) under the bubble, nothing before an echo; DM: ✓ delivered, ✗ (red) not delivered, "..." sending
- [ ] Hold a message: quote, time + hops, path diagram sender → repeaters → this device; own post: "Relayed by n repeaters" + names; own DM: Delivered / Not delivered / Sending in words
- [ ] Map download popup: no WiFi button, fits the screen (scrolls if the unfinished-job row is shown); no network saved → toast pointing to Settings > WiFi
+36 -2
View File
@@ -82,8 +82,32 @@ L1 splits its tools into many small screens because of the joystick and the
## 6. Theme
- [ ] Develop the current look into a consistent, recognisable style
(colours, corners, type, icons, states), after the layout of stage 4.
- [x] A consistent style, written down in `Theme.h`: accent fill = the
primary action (Download, Install, Save, Go), dim accent fill =
selected / on (tabs, chips, segments -- also the default theme's
CHECKED), accent text = names, counts, modes; one card radius for
buttons and rows, pills for chips, `RADIUS_SM` inside; slightly lighter
surfaces. Accent colour selectable (Settings > Display & power > LOOK:
amber, orange, coral, violet, cyan, lime; NVS `mc_ui`).
- [x] Light motion (`Anim.h`): a new screen emerges from the middle (a
background-coloured cover fades while the content drifts 6 px), Home
pages slide after a swipe, popups and toasts rise and fade in, buttons
shrink a little while pressed.
- [x] Frame time measured on the device (`-D UI_PERF_TEST`: walks screens by
itself, prints render + flush per refresh): ~48 → ~33 ms per transition
frame with uncompressed fonts and two 120-line buffers. Internal-RAM /
DMA buffers, `-O2`, hot code in IRAM, two draw threads, system malloc:
no gain. The rest is LVGL's software rendering at 320x240.
- [x] Status bar icons in equal cells; muted is a speaker with a cross.
- [x] Under an own message: the age and a small mark as in L1 (✓ n repeaters
for channel posts; ✓ / ✗ / ... for DMs) instead of the words.
- [x] The path window of a message: quote, time and hops, a path diagram
(sender → repeaters → this device), repeaters that relayed an own post,
an own DM's delivery in words.
- [x] Tile download popup fits the screen; its WiFi button went (WiFi is set
up in one place, Settings).
- [x] Splash screen (`Splash.h`): MeshCore wordmark, SOLO, the Solo and the
upstream version, build date, loading dots.
## 7. Research: LVGL for the other displays
@@ -100,3 +124,13 @@ L1 splits its tools into many small screens because of the joystick and the
chip / register (devices on the bus: 0x14, 0x18, 0x21, 0x22, 0x2c,
0x34, 0x48, 0x5d). Found 2026-09-26.
Ideas to come back to (2026-09-26):
- [ ] Speaker click: would keeping the amplifier on at minimum volume
remove it?
- [ ] Import routes from the SD card (optional extra).
- [ ] Battery life without losing features, above all CPU sleep.
- [ ] Live tiles: don't keep them, or give them a bounded cache on the card
so they never fill it.
- [ ] Vector maps and other tile sources (to think through).
@@ -141,7 +141,7 @@ void UITask::buildAdmin() {
lv_obj_remove_event_cb(s_pw, onWifiField);
lv_textarea_set_max_length(s_pw, 15);
lv_obj_add_state(s_pw, LV_STATE_FOCUSED);
s_kb = kb::create(lv_screen_active(), _prefs);
s_kb = kb::create(screen(), _prefs);
lv_obj_set_size(s_kb, LV_PCT(100), 124);
lv_obj_align(s_kb, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_keyboard_set_textarea(s_kb, s_pw);
+78
View File
@@ -0,0 +1,78 @@
#pragma once
// Light motion for ui-lvgl: screens emerge from the middle, popups and toasts
// rise and fade in, Home pages slide after a swipe (buttons giving under the
// finger is a Theme.h style). Short (under 200 ms) and eased, so nothing waits
// on them.
namespace anim {
static const uint32_t SCREEN_MS = 180; // screen change
static const uint32_t POP_MS = 160; // popup / toast in
static const uint32_t OUT_MS = 120; // toast out
static const uint32_t PAGE_MS = 160; // Home page after a swipe
static void setTy(void* o, int32_t v) { lv_obj_set_style_translate_y((lv_obj_t*)o, v, 0); }
static void setTx(void* o, int32_t v) { lv_obj_set_style_translate_x((lv_obj_t*)o, v, 0); }
static void setOpa(void* o, int32_t v) { lv_obj_set_style_opa((lv_obj_t*)o, (lv_opa_t)v, 0); }
static void setBgOpa(void* o, int32_t v) { lv_obj_set_style_bg_opa((lv_obj_t*)o, (lv_opa_t)v, 0); }
static void run(lv_obj_t* o, lv_anim_exec_xcb_t cb, int32_t from, int32_t to, uint32_t ms,
lv_anim_completed_cb_t done = nullptr) {
lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_var(&a, o);
lv_anim_set_exec_cb(&a, cb);
lv_anim_set_values(&a, from, to);
lv_anim_set_duration(&a, ms);
lv_anim_set_path_cb(&a, lv_anim_path_ease_out);
if (done) lv_anim_set_completed_cb(&a, done);
lv_anim_start(&a);
cb(o, from); // no frame of the end state before the first tick
}
// Rise by dy and fade in (a popup's panel, a toast).
static void rise(lv_obj_t* o, int32_t dy = 16, uint32_t ms = POP_MS) {
run(o, setTy, dy, 0, ms);
run(o, setOpa, LV_OPA_TRANSP, LV_OPA_COVER, ms);
}
// A popup: the dimmed backdrop fades up to `dim`, its first child (the
// panel) rises.
static void popup(lv_obj_t* overlay, lv_opa_t dim = LV_OPA_60) {
run(overlay, setBgOpa, LV_OPA_TRANSP, dim, POP_MS);
lv_obj_t* panel = lv_obj_get_child(overlay, 0);
if (panel) rise(panel);
}
// A new screen emerges: a cover in the background colour fades away over
// it while its content drifts a few pixels into place (up going deeper,
// down coming back). A plain fill blended on top -- far cheaper than
// fading the screen itself (a full-screen layer) or sliding two screens.
static void coverDone(lv_anim_t* a) { lv_obj_delete((lv_obj_t*)a->var); }
static void screenIn(lv_obj_t* scr, lv_obj_t* body, bool back) {
lv_obj_t* cover = lv_obj_create(scr);
lv_obj_remove_style_all(cover);
lv_obj_remove_flag(cover, LV_OBJ_FLAG_CLICKABLE); // taps go through to the screen
lv_obj_add_flag(cover, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_set_size(cover, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(cover, lv_obj_get_style_bg_color(scr, 0), 0);
run(cover, setBgOpa, LV_OPA_COVER, LV_OPA_TRANSP, SCREEN_MS, coverDone);
if (body) run(body, setTy, back ? -6 : 6, 0, SCREEN_MS);
}
// Content that replaced other content in place: slides in from dx.
static void slideIn(lv_obj_t* o, int32_t dx, uint32_t ms = PAGE_MS) {
run(o, setTx, dx, 0, ms);
run(o, setOpa, LV_OPA_40, LV_OPA_COVER, ms);
}
static void hideDone(lv_anim_t* a) {
lv_obj_t* o = (lv_obj_t*)a->var;
lv_obj_add_flag(o, LV_OBJ_FLAG_HIDDEN);
lv_obj_set_style_opa(o, LV_OPA_COVER, 0);
}
// Fade out, then hide (the object stays for reuse).
static void fadeHide(lv_obj_t* o) {
lv_anim_delete(o, setOpa);
run(o, setOpa, lv_obj_get_style_opa(o, 0), LV_OPA_TRANSP, OUT_MS, hideDone);
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ static void botChip(const char* ph, void* parent) {
lv_obj_set_height(b, 26);
lv_obj_set_style_pad_hor(b, 7, 0);
lv_obj_set_style_pad_ver(b, 0, 0);
lv_obj_set_style_radius(b, 13, 0);
lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), 0);
lv_obj_add_event_cb(b, onBotPlaceholder, LV_EVENT_CLICKED, NULL);
@@ -230,6 +230,7 @@ void UITask::buildChannelEdit() {
lv_obj_set_style_bg_color(save, lv_color_hex(theme::ACCENT_DIM), 0);
lv_obj_add_event_cb(save, onChanSave, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(save, LV_SYMBOL_OK " Save", THEME_FONT_BODY, theme::TEXT));
stylePrimary(save);
s_status = label(body, "", THEME_FONT_SMALL, theme::FAIL);
lv_label_set_long_mode(s_status, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_status, LV_PCT(100));
@@ -244,7 +245,7 @@ void UITask::buildChannelEdit() {
lv_label_set_text_fmt(s_hint, "Key: %s", hex);
}
s_kb = kb::create(lv_screen_active(), _prefs);
s_kb = kb::create(screen(), _prefs);
lv_obj_set_size(s_kb, LV_PCT(100), 124);
lv_obj_align(s_kb, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_add_event_cb(s_kb, onChanKb, LV_EVENT_READY, NULL);
@@ -303,7 +304,7 @@ void UITask::channelEditField(lv_obj_t* ta) {
lv_obj_remove_flag(s_kb, LV_OBJ_FLAG_HIDDEN);
if (_body) lv_obj_set_height(_body, lv_obj_get_height(_body) - lv_obj_get_height(s_kb));
}
lv_obj_update_layout(lv_screen_active());
lv_obj_update_layout(screen());
lv_obj_scroll_to_view(ta, LV_ANIM_OFF);
}
@@ -202,6 +202,7 @@ void UITask::buildClock() {
lv_obj_add_event_cb(go, onClockAct, LV_EVENT_CLICKED, (void*)(uintptr_t)ACT_START_STOP);
s_go_lbl = label(go, "", THEME_FONT_BODY, theme::TEXT);
lv_obj_center(s_go_lbl);
stylePrimary(go);
if (s_tab == TAB_STOPWATCH) {
lv_obj_t* rs = lv_button_create(acts);
lv_obj_set_size(rs, 100, 40);
@@ -292,6 +293,7 @@ void UITask::showRing(const char* text) {
lv_obj_set_style_bg_color(b, lv_color_hex(theme::ACCENT_DIM), 0);
lv_obj_add_event_cb(b, onRingDismiss, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(b, "Dismiss", THEME_FONT_LARGE, theme::TEXT));
stylePrimary(b);
}
lv_label_set_text(s_ring_lbl, text);
lv_obj_remove_flag(s_ring, LV_OBJ_FLAG_HIDDEN);
@@ -227,6 +227,35 @@ void UITask::conversationAction(uint8_t act) {
// ── Message actions ───────────────────────────────────────────────────────────
// One stop on the path diagram: a dot on a vertical line (the line runs on to
// the next stop unless `last`), the name beside it.
static void pathStop(lv_obj_t* parent, const char* name, bool first, bool last, uint32_t col) {
lv_obj_t* row = lv_obj_create(parent);
lv_obj_remove_style_all(row);
lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(row, LV_PCT(100), 20);
if (!first || !last) {
lv_obj_t* line = lv_obj_create(row);
lv_obj_remove_style_all(line);
lv_obj_set_style_bg_opa(line, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(line, lv_color_hex(theme::SURFACE_2), 0);
lv_obj_set_size(line, 2, first || last ? 10 : 20);
lv_obj_set_pos(line, 6, first ? 10 : 0);
}
lv_obj_t* dot = lv_obj_create(row);
lv_obj_remove_style_all(dot);
lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(dot, lv_color_hex(col), 0);
lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0);
bool end = first || last;
lv_obj_set_size(dot, end ? 10 : 8, end ? 10 : 8);
lv_obj_align(dot, LV_ALIGN_LEFT_MID, end ? 2 : 3, 0);
lv_obj_t* t = label(row, name, end ? THEME_FONT_BODY : THEME_FONT_SMALL, end ? theme::TEXT : theme::TEXT_MUTED);
lv_label_set_long_mode(t, LV_LABEL_LONG_DOT);
lv_obj_set_width(t, 240);
lv_obj_align(t, LV_ALIGN_LEFT_MID, 22, 0);
}
void UITask::messageMenu(int idx) {
using namespace convview;
if (idx < 0 || idx >= s_msg_meta_n) return;
@@ -234,34 +263,93 @@ void UITask::messageMenu(int idx) {
s_msg = idx;
lv_obj_t* panel = navPopupPanel(m.own ? "My message" : (m.from[0] ? m.from : "Message"), false);
// The way it came: hops of an incoming message, or the repeaters heard
// relaying our own channel post.
const MessageHistory& h = _core->history;
uint8_t packed = 0; const uint8_t* path = nullptr; bool relay = false;
uint32_t ts = 0; const char* body = "";
AckState dm_st = ACK_NONE;
if (m.pos >= 0) {
if (m.channel) { const ChHistEntry& e = h.chAtPos(m.pos); packed = e.path_len; path = e.path; relay = e.relay_seq != 0; }
else { const DmHistEntry& e = h.dmAtPos(m.pos); packed = e.path_len; path = e.path; }
if (m.channel) {
const ChHistEntry& e = h.chAtPos(m.pos);
packed = e.path_len; path = e.path; relay = m.own; ts = e.timestamp;
const char* sep = strstr(e.text, ": ");
body = sep ? sep + 2 : e.text;
} else {
const DmHistEntry& e = h.dmAtPos(m.pos);
packed = e.path_len; path = e.path; ts = e.timestamp; body = e.text;
if (e.outgoing) dm_st = h.dmEffectiveStatus(e);
else {
ContactInfo tc; // a room post is filed "Author: text"
if (MessageHistory::contactByPrefix(_thread_key, tc) && tc.type == ADV_TYPE_ROOM) {
const char* sep = strstr(e.text, ": ");
if (sep) body = sep + 2;
}
}
}
}
// The message itself, quoted short, and when.
lv_obj_t* q = label(panel, body, THEME_FONT_SMALL, theme::TEXT);
lv_label_set_long_mode(q, LV_LABEL_LONG_DOT);
lv_obj_set_width(q, LV_PCT(100));
lv_obj_set_style_max_height(q, 34, 0); // two lines
lv_obj_set_style_border_side(q, LV_BORDER_SIDE_LEFT, 0);
lv_obj_set_style_border_width(q, 2, 0);
lv_obj_set_style_border_color(q, lv_color_hex(m.own ? theme::ACCENT : theme::SURFACE_2), 0);
lv_obj_set_style_pad_left(q, 8, 0);
char when[64], age[16];
uint32_t now = rtc_clock.getCurrentTime();
geo::fmtAgeShort(age, sizeof(age), now, ts ? ts : now);
if (ts > 1000000000UL) {
time_t t = (time_t)((int64_t)ts + (int64_t)(_prefs ? _prefs->tz_offset_hours : 0) * 3600);
struct tm ti = *gmtime(&t);
char clk[12];
fmtClock(clk, sizeof(clk), ti, _prefs, true);
snprintf(when, sizeof(when), "%s - %s ago", clk, age);
} else {
snprintf(when, sizeof(when), "%s ago", age);
}
uint8_t hops = contactctl::hopCount(packed);
char text[200];
int o = 0;
if (hops > 0) {
o = snprintf(text, sizeof(text), relay ? "Relayed by: " : "Path (%u hop%s): ", hops, hops == 1 ? "" : "s");
for (uint8_t i = 0; i < hops && o < (int)sizeof(text) - 40; i++) {
if (!m.own) { // incoming: how far it came, on the same line
size_t o = strlen(when);
if (hops > 0) snprintf(when + o, sizeof(when) - o, " - %u hop%s", hops, hops == 1 ? "" : "s");
else snprintf(when + o, sizeof(when) - o, " - direct");
}
label(panel, when, THEME_FONT_SMALL, theme::TEXT_MUTED);
if (m.own && !m.channel) { // a DM we sent: its end-to-end delivery, in words
const char* st = "Sent"; uint32_t col = theme::TEXT_MUTED;
switch (dm_st) {
case ACK_OK: st = LV_SYMBOL_OK " Delivered"; col = theme::OK; break;
case ACK_FAIL: st = LV_SYMBOL_CLOSE " Not delivered"; col = theme::FAIL; break;
case ACK_PENDING: st = "Sending..."; break;
default: break;
}
label(panel, st, THEME_FONT_BODY, col);
} else if (relay) { // our channel post: the repeaters heard passing it on
char hd[40];
if (hops > 0) snprintf(hd, sizeof(hd), LV_SYMBOL_OK " Relayed by %u repeater%s", hops, hops == 1 ? "" : "s");
else snprintf(hd, sizeof(hd), "No repeater heard relaying it yet");
label(panel, hd, THEME_FONT_BODY, hops > 0 ? theme::OK : theme::TEXT_MUTED);
for (uint8_t i = 0; i < hops; i++) {
char nm[33], line[40];
contactctl::hopName(packed, path, i, nm, sizeof(nm));
snprintf(line, sizeof(line), UI_SYMBOL_RADIO " %s", nm);
label(panel, line, THEME_FONT_SMALL, theme::TEXT_MUTED);
}
} else { // incoming: sender, each hop, then us
lv_obj_t* diag = lv_obj_create(panel);
lv_obj_remove_style_all(diag);
lv_obj_remove_flag(diag, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(diag, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(diag, LV_FLEX_FLOW_COLUMN);
pathStop(diag, m.from[0] ? m.from : "Sender", true, false, theme::ACCENT);
for (uint8_t i = 0; i < hops; i++) {
char nm[33];
contactctl::hopName(packed, path, i, nm, sizeof(nm));
o += snprintf(text + o, sizeof(text) - o, "%s%s", i ? (relay ? ", " : " > ") : "", nm);
pathStop(diag, nm, false, false, theme::TEXT_MUTED);
}
} else if (!m.own) {
snprintf(text, sizeof(text), "Heard directly (no repeaters)");
} else if (m.channel) {
snprintf(text, sizeof(text), "No repeater heard relaying it yet");
} else {
snprintf(text, sizeof(text), "Delivery is shown under the message"); // no path kept for our own DMs
pathStop(diag, the_mesh.getNodeName(), false, true, theme::OK);
}
lv_obj_t* t = label(panel, text, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
lv_obj_set_width(t, LV_PCT(100));
bool can_reply = !m.own && m.from[0] && _compose_ta;
if (!can_reply && m.loc < 0) return;
@@ -220,7 +220,7 @@ void UITask::lockScreen() {
lv_obj_set_style_anim_duration(s_lock_slider, 0, 0); // no lag behind the finger
lv_obj_set_style_bg_color(s_lock_slider, lv_color_hex(theme::SURFACE), LV_PART_MAIN);
lv_obj_set_style_bg_opa(s_lock_slider, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_radius(s_lock_slider, 22, LV_PART_MAIN);
lv_obj_set_style_radius(s_lock_slider, LV_RADIUS_CIRCLE, LV_PART_MAIN);
lv_obj_set_style_bg_opa(s_lock_slider, LV_OPA_TRANSP, LV_PART_INDICATOR);
lv_obj_set_style_bg_color(s_lock_slider, lv_color_hex(theme::ACCENT), LV_PART_KNOB);
lv_obj_set_style_pad_all(s_lock_slider, 2, LV_PART_KNOB);
@@ -388,6 +388,51 @@ void UITask::pinRowRefresh() {
if (_body) { lv_obj_update_layout(_body); lv_obj_scroll_by(_body, 0, -lv_obj_get_scroll_bottom(_body), LV_ANIM_OFF); }
}
// ── Accent colour ─────────────────────────────────────────────────────────────
static void onAccent(lv_event_t* e) { s_ui->setAccent((int)(uintptr_t)lv_event_get_user_data(e)); }
// A row of colour swatches; the current one ringed.
void UITask::accentRow(lv_obj_t* body) {
lv_obj_t* card = lv_obj_create(body);
styleSurface(card, theme::SURFACE);
lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_radius(card, theme::RADIUS, 0);
lv_obj_set_style_pad_all(card, theme::PAD, 0);
lv_obj_set_style_pad_row(card, 8, 0);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
label(card, "Accent colour", THEME_FONT_BODY, theme::TEXT);
lv_obj_t* row = lv_obj_create(card);
lv_obj_remove_style_all(row);
lv_obj_set_size(row, LV_PCT(100), 36);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
for (int i = 0; i < theme::ACCENT_COUNT; i++) {
lv_obj_t* sw = lv_button_create(row);
lv_obj_set_size(sw, 32, 32);
lv_obj_set_style_radius(sw, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(sw, lv_color_hex(theme::ACCENTS[i].col), 0);
bool cur = i == theme::s_accent;
lv_obj_set_style_border_width(sw, cur ? 3 : 0, 0);
lv_obj_set_style_border_color(sw, lv_color_hex(theme::TEXT), 0);
lv_obj_add_event_cb(sw, onAccent, LV_EVENT_CLICKED, (void*)(uintptr_t)i);
if (cur) lv_obj_center(label(sw, LV_SYMBOL_OK, THEME_FONT_SMALL, theme::BG));
}
}
// Recolours everything: the theme, the status bar and toast, then this page.
void UITask::setAccent(int idx) {
if (idx == theme::s_accent) return;
theme::setAccent(idx);
lvport::saveAccent(theme::s_accent);
theme::install(lv_display_get_default());
if (_toast) lv_obj_set_style_border_color(_toast, lv_color_hex(theme::ACCENT), 0);
_status_sig[0] = '\0'; // icons in the accent rebuilt
refreshStatusBar();
pinRowRefresh(); // the page, kept at its bottom where this row is
}
// ── Favourites dial ───────────────────────────────────────────────────────────
void UITask::showFavourites() {
+30 -2
View File
@@ -29,14 +29,21 @@ static const int WIFI_SCAN_MAX = 12;
static lgfx::LGFX_Device* s_gfx = nullptr;
static bool s_swallow = false; // ignore the touch that woke the display until it lifts
static uint32_t s_flush_us = 0; // UI_PERF_TEST: time spent in flushCb
static void flushCb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) {
#ifdef UI_PERF_TEST
uint32_t t = micros();
#endif
int w = area->x2 - area->x1 + 1;
int h = area->y2 - area->y1 + 1;
s_gfx->startWrite();
s_gfx->setAddrWindow(area->x1, area->y1, w, h);
s_gfx->pushPixels((uint16_t*)px_map, (uint32_t)w * h, true /* LVGL RGB565 is little-endian */);
s_gfx->endWrite();
#ifdef UI_PERF_TEST
s_flush_us += micros() - t;
#endif
lv_display_flush_ready(disp);
}
@@ -65,8 +72,11 @@ static bool begin() {
lv_display_t* disp = lv_display_create(s_gfx->width(), s_gfx->height());
lv_display_set_flush_cb(disp, flushCb);
// Two 40-line partial buffers (25 KB each), PSRAM first.
const uint32_t buf_sz = (uint32_t)s_gfx->width() * 40 * 2;
// Two half-screen buffers (75 KB each) in PSRAM: a frame renders in two
// passes instead of six (every pass walks the whole tree and lays text out
// again) -- measured ~8% faster than 40 lines; internal RAM / DMA / -O2
// made no difference (UI_PERF_TEST).
const uint32_t buf_sz = (uint32_t)s_gfx->width() * 120 * 2;
uint8_t* buf1 = (uint8_t*)heap_caps_malloc(buf_sz, MALLOC_CAP_SPIRAM);
uint8_t* buf2 = (uint8_t*)heap_caps_malloc(buf_sz, MALLOC_CAP_SPIRAM);
if (!buf1) { buf1 = (uint8_t*)heap_caps_malloc(buf_sz, MALLOC_CAP_8BIT); buf2 = nullptr; }
@@ -168,6 +178,21 @@ static void savePin(const char* pin) {
p.end();
}
// Accent colour (Settings > Display & power): an index into theme::ACCENTS.
static int loadAccent() {
Preferences p;
if (!p.begin("mc_ui", true)) return 0;
int v = p.getUChar("accent", 0);
p.end();
return v;
}
static void saveAccent(int idx) {
Preferences p;
if (!p.begin("mc_ui", false)) return;
p.putUChar("accent", (uint8_t)idx);
p.end();
}
static void netBegin(const char* ssid, const char* pass) {
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, pass);
@@ -398,6 +423,9 @@ static void saveWifi(const char* ssid, const char* pass) {
static char s_pin[9] = ""; // the screen PIN, for the session
static void loadPin(char* out, size_t n) { snprintf(out, n, "%s", s_pin); }
static void savePin(const char* pin) { snprintf(s_pin, sizeof(s_pin), "%s", pin); }
static int s_accent = 0;
static int loadAccent() { return s_accent; }
static void saveAccent(int idx) { s_accent = idx; }
static bool s_wifi_on = true;
static bool wifiAllowed() { return s_wifi_on; }
static void setWifiAllowed(bool on) { s_wifi_on = on; }
+9 -17
View File
@@ -149,7 +149,6 @@ static void onDlClose(lv_event_t* e) { (void)e; s_ui->mapDownloadClose(); }
static void onDlStart(lv_event_t* e) { (void)e; s_ui->mapDownloadStart(); }
static void onDlZoomMinus(lv_event_t* e) { (void)e; s_ui->mapDownloadZmax(-1); }
static void onDlZoomPlus(lv_event_t* e) { (void)e; s_ui->mapDownloadZmax(+1); }
static void onDlWifi(lv_event_t* e) { (void)e; s_ui->showWifi(true); }
static void onDlResume(lv_event_t* e) { (void)e; s_ui->mapDownloadResume(); }
static void onDlDiscard(lv_event_t* e) { (void)e; s_ui->mapDownloadDiscard(); }
static void onNavTools(lv_event_t* e); // NavMap.h
@@ -580,7 +579,7 @@ void UITask::mapDownloadPopup() {
if (_dl_overlay) return;
if (_dl_zmax < _map_z) _dl_zmax = _map_z + 3 > 17 ? 17 : _map_z + 3;
_dl_overlay = lv_obj_create(lv_screen_active());
_dl_overlay = lv_obj_create(screen());
lv_obj_remove_style_all(_dl_overlay);
lv_obj_set_size(_dl_overlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(_dl_overlay, lv_color_hex(0x000000), 0);
@@ -589,6 +588,7 @@ void UITask::mapDownloadPopup() {
lv_obj_remove_flag(_dl_overlay, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* panel = lv_obj_create(_dl_overlay);
anim::popup(_dl_overlay);
lv_obj_set_size(panel, lv_display_get_horizontal_resolution(NULL) - 16, LV_SIZE_CONTENT);
lv_obj_align(panel, LV_ALIGN_CENTER, 0, theme::STATUS_H / 2);
lv_obj_set_style_bg_color(panel, lv_color_hex(theme::BG), 0);
@@ -598,7 +598,7 @@ void UITask::mapDownloadPopup() {
lv_obj_set_style_pad_all(panel, theme::PAD, 0);
lv_obj_set_style_pad_row(panel, 6, 0);
lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN);
lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_max_height(panel, lv_display_get_vertical_resolution(NULL) - theme::STATUS_H - 12, 0); // scrolls past that
lv_obj_t* hdr = lv_obj_create(panel);
styleSurface(hdr, theme::BG);
@@ -628,8 +628,7 @@ void UITask::mapDownloadPopup() {
lv_obj_set_width(_dl_job_lbl, 170);
lv_label_set_long_mode(_dl_job_lbl, LV_LABEL_LONG_WRAP);
lv_obj_align(_dl_job_lbl, LV_ALIGN_LEFT_MID, theme::PAD, 0);
lv_obj_t* res = headerButton(_dl_job_row, LV_SYMBOL_PLAY " Resume", onDlResume, 48, NULL);
lv_obj_set_style_bg_color(res, lv_color_hex(theme::ACCENT_DIM), 0);
stylePrimary(headerButton(_dl_job_row, LV_SYMBOL_PLAY " Resume", onDlResume, 48, NULL));
headerButton(_dl_job_row, LV_SYMBOL_TRASH, onDlDiscard, 4, NULL);
lv_obj_add_flag(_dl_job_row, LV_OBJ_FLAG_HIDDEN);
@@ -644,23 +643,16 @@ void UITask::mapDownloadPopup() {
lv_obj_set_size(acts, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(acts, LV_FLEX_FLOW_ROW);
lv_obj_set_style_pad_column(acts, theme::GAP, 0);
lv_obj_t* wifi = lv_button_create(acts);
lv_obj_set_height(wifi, 38);
lv_obj_set_flex_grow(wifi, 1);
lv_obj_set_style_shadow_width(wifi, 0, 0);
lv_obj_set_style_radius(wifi, theme::RADIUS, 0);
lv_obj_set_style_bg_color(wifi, lv_color_hex(theme::SURFACE), 0);
lv_obj_add_event_cb(wifi, onDlWifi, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(wifi, LV_SYMBOL_WIFI " WiFi", THEME_FONT_SMALL, theme::TEXT));
lv_obj_t* go = lv_button_create(acts);
lv_obj_set_height(go, 38);
lv_obj_set_flex_grow(go, 2);
lv_obj_set_flex_grow(go, 1);
lv_obj_set_style_shadow_width(go, 0, 0);
lv_obj_set_style_radius(go, theme::RADIUS, 0);
lv_obj_set_style_bg_color(go, lv_color_hex(theme::ACCENT_DIM), 0);
lv_obj_add_event_cb(go, onDlStart, LV_EVENT_CLICKED, NULL);
_dl_start_lbl = label(go, "", THEME_FONT_SMALL, theme::TEXT);
lv_obj_center(_dl_start_lbl);
stylePrimary(go);
refreshDownloadPopup();
}
@@ -723,7 +715,7 @@ void UITask::refreshDownloadPopup() {
else snprintf(size, sizeof(size), "%lu MB", (unsigned long)((bytes + 512 * 1024) / (1024 * 1024)));
lv_label_set_text_fmt(_dl_info, "z%d-%d: %lu tiles, about %s - from %s\nWiFi: %s%s",
a.zmin, a.zmax, (unsigned long)n, size,
dl.sourceHost(), have_wifi ? ssid : "not set - tap WiFi", last);
dl.sourceHost(), have_wifi ? ssid : "none - Settings > WiFi", last);
lv_label_set_text(_dl_start_lbl, n > mapview::TileDownloader::MAX_TILES ? "Too large - zoom in"
: LV_SYMBOL_DOWNLOAD " Download");
}
@@ -732,7 +724,7 @@ void UITask::mapDownloadStart() {
if (mapview::s_dl.active()) { mapDownloadStop(); return; }
char ssid[33], pass[65];
if (!lvport::wifiAllowed()) { showToast("WiFi is off - Settings > WiFi"); return; }
if (!lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass))) { showWifi(true); return; }
if (!lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass))) { showToast("Pick a WiFi network first - Settings > WiFi", 3000); return; }
if (!lvport::mountStorage()) { showToast("No SD card"); return; }
int w = _map_area ? lv_obj_get_width(_map_area) : 320, h = _map_area ? lv_obj_get_height(_map_area) : 218;
mapview::TileArea a = visibleArea(_map_cx, _map_cy, _map_z, w, h, dlZmin(_map_z), _dl_zmax);
@@ -745,7 +737,7 @@ void UITask::mapDownloadResume() {
if (mapview::s_dl.active() || !mapview::s_dl.savedJob(a)) return;
char ssid[33], pass[65];
if (!lvport::wifiAllowed()) { showToast("WiFi is off - Settings > WiFi"); return; }
if (!lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass))) { showWifi(true); return; }
if (!lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass))) { showToast("Pick a WiFi network first - Settings > WiFi", 3000); return; }
if (!lvport::mountStorage()) { showToast("No SD card"); return; }
if (!mapview::s_dl.start(a, ssid, pass)) { showToast(mapview::s_dl.message()); return; }
refreshDownloadPopup();
+2 -1
View File
@@ -440,7 +440,7 @@ void UITask::navToNode(const uint8_t* key, int32_t lat, int32_t lon, const char*
lv_obj_t* UITask::navPopupPanel(const char* title, bool full) {
navClosePopup();
_nav_overlay = lv_obj_create(lv_screen_active());
_nav_overlay = lv_obj_create(screen());
lv_obj_remove_style_all(_nav_overlay);
lv_obj_set_size(_nav_overlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(_nav_overlay, lv_color_hex(0x000000), 0);
@@ -449,6 +449,7 @@ lv_obj_t* UITask::navPopupPanel(const char* title, bool full) {
lv_obj_remove_flag(_nav_overlay, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* panel = lv_obj_create(_nav_overlay);
anim::popup(_nav_overlay);
int w = lv_display_get_horizontal_resolution(NULL) - 16;
if (full) {
lv_obj_set_size(panel, w, lv_display_get_vertical_resolution(NULL) - theme::STATUS_H - 12);
@@ -196,6 +196,7 @@ void UITask::buildOta() {
_ota_btn = b;
_ota_btn_lbl = label(b, "", THEME_FONT_BODY, theme::TEXT);
lv_obj_center(_ota_btn_lbl);
stylePrimary(b);
lv_obj_t* h = label(body, "From github.com/" OTA_REPO " releases, over WiFi. About a minute; keep the device "
"charged. Settings, contacts and messages are kept.", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(h, LV_LABEL_LONG_WRAP);
@@ -30,7 +30,7 @@ static lv_obj_t* chips(lv_obj_t* parent, lv_event_cb_t cb, bool wrap) {
lv_obj_set_size(b, LV_SIZE_CONTENT, 28);
lv_obj_set_style_pad_hor(b, 8, 0);
lv_obj_set_style_pad_ver(b, 0, 0);
lv_obj_set_style_radius(b, 8, 0);
lv_obj_set_style_radius(b, theme::RADIUS_SM, 0);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), 0);
lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, (void*)(uintptr_t)i);
@@ -128,7 +128,7 @@ void UITask::quickEdit(int slot) {
collect();
s_edit = slot;
navClosePopup();
_nav_overlay = lv_obj_create(lv_screen_active());
_nav_overlay = lv_obj_create(screen());
lv_obj_remove_style_all(_nav_overlay);
lv_obj_set_size(_nav_overlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(_nav_overlay, lv_color_hex(0x000000), 0);
@@ -170,13 +170,14 @@ void UITask::radioFreqPopup(bool repeater) {
using namespace radioview;
if (s_overlay) return;
s_freq_rpt = repeater;
s_overlay = lv_obj_create(lv_screen_active());
s_overlay = lv_obj_create(screen());
lv_obj_remove_style_all(s_overlay);
lv_obj_set_size(s_overlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(s_overlay, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(s_overlay, LV_OPA_60, 0);
lv_obj_add_flag(s_overlay, LV_OBJ_FLAG_CLICKABLE);
lv_obj_t* panel = lv_obj_create(s_overlay);
anim::popup(s_overlay);
lv_obj_set_size(panel, lv_display_get_horizontal_resolution(NULL) - 16, LV_SIZE_CONTENT);
lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4);
lv_obj_set_style_bg_color(panel, lv_color_hex(theme::BG), 0);
@@ -97,7 +97,7 @@ static void rebuildStrip() {
lv_obj_t* c = lv_button_create(s_strip);
lv_obj_set_size(c, 38, 44);
lv_obj_set_style_pad_all(c, 0, 0);
lv_obj_set_style_radius(c, 6, 0);
lv_obj_set_style_radius(c, theme::RADIUS_SM, 0);
lv_obj_set_style_shadow_width(c, 0, 0);
lv_obj_set_style_border_color(c, lv_color_hex(theme::ACCENT), 0);
lv_obj_add_event_cb(c, onMelNote, LV_EVENT_CLICKED, (void*)(uintptr_t)i);
@@ -142,7 +142,7 @@ static lv_obj_t* smallButton(lv_obj_t* parent, const char* text, uint8_t act) {
lv_obj_t* b = lv_button_create(parent);
lv_obj_set_size(b, 40, 32);
lv_obj_set_style_pad_all(b, 0, 0);
lv_obj_set_style_radius(b, 8, 0);
lv_obj_set_style_radius(b, theme::RADIUS_SM, 0);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), 0);
lv_obj_add_event_cb(b, onMelAction, LV_EVENT_CLICKED, (void*)(uintptr_t)act);
+152
View File
@@ -0,0 +1,152 @@
// Boot splash -- included into UITask.cpp only.
//
// The MeshCore wordmark (ui-new's 128x13 bitmap, drawn 2x in amber), "SOLO",
// the Solo and upstream versions and the build date, with three dots rising
// in turn while the rest of the device starts. Covers the whole display
// (status bar too) from lv_layer_top, fades out after SPLASH_MS; a tap skips
// it. Home -- and the PIN lock, if set -- are already built underneath.
namespace splash {
static const uint32_t SPLASH_MS = 2500;
static const int LOGO_W = 128, LOGO_H = 13, LOGO_SCALE = 2;
// 'meshcore', 128x13 px, MSB first (ui-new/icons.h meshcore_logo)
static const uint8_t LOGO[] = {
0x3c, 0x01, 0xe3, 0xff, 0xc7, 0xff, 0x8f, 0x03, 0x87, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe,
0x3c, 0x03, 0xe3, 0xff, 0xc7, 0xff, 0x8e, 0x03, 0x8f, 0xfe, 0x3f, 0xfe, 0x1f, 0xff, 0x1f, 0xfe,
0x3e, 0x03, 0xc3, 0xff, 0x8f, 0xff, 0x0e, 0x07, 0x8f, 0xfe, 0x7f, 0xfe, 0x1f, 0xff, 0x1f, 0xfc,
0x3e, 0x07, 0xc7, 0x80, 0x0e, 0x00, 0x0e, 0x07, 0x9e, 0x00, 0x78, 0x0e, 0x3c, 0x0f, 0x1c, 0x00,
0x3e, 0x0f, 0xc7, 0x80, 0x1e, 0x00, 0x0e, 0x07, 0x1e, 0x00, 0x70, 0x0e, 0x38, 0x0f, 0x3c, 0x00,
0x7f, 0x0f, 0xc7, 0xfe, 0x1f, 0xfc, 0x1f, 0xff, 0x1c, 0x00, 0x70, 0x0e, 0x38, 0x0e, 0x3f, 0xf8,
0x7f, 0x1f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x0e, 0x38, 0x0e, 0x3f, 0xf8,
0x7f, 0x3f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x1e, 0x3f, 0xfe, 0x3f, 0xf0,
0x77, 0x3b, 0x87, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xfc, 0x38, 0x00,
0x77, 0xfb, 0x8f, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xf8, 0x38, 0x00,
0x73, 0xf3, 0x8f, 0xff, 0x0f, 0xff, 0x1c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x78, 0x7f, 0xf8,
0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfe, 0x3c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x3c, 0x7f, 0xf8,
0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8,
};
static lv_obj_t* s_root = nullptr;
static uint32_t* s_px = nullptr; // ARGB8888 pixels of the scaled logo
static lv_image_dsc_t s_img;
static lv_timer_t* s_timer = nullptr;
static void freeAll(lv_anim_t* a) {
(void)a;
if (s_root) lv_obj_delete(s_root);
s_root = nullptr;
if (s_px) lv_free(s_px);
s_px = nullptr;
}
static void dismiss() {
if (s_timer) { lv_timer_delete(s_timer); s_timer = nullptr; }
if (!s_root) return;
lv_obj_remove_flag(s_root, LV_OBJ_FLAG_CLICKABLE);
lv_anim_delete(s_root, NULL);
anim::run(s_root, anim::setOpa, LV_OPA_COVER, LV_OPA_TRANSP, 300, freeAll);
}
static void onTimer(lv_timer_t* t) { (void)t; dismiss(); }
static void onTap(lv_event_t* e) { (void)e; dismiss(); }
// The logo bitmap scaled up into an ARGB8888 image in `col`.
static bool buildLogo(uint32_t col) {
const int W = LOGO_W * LOGO_SCALE, H = LOGO_H * LOGO_SCALE;
s_px = (uint32_t*)lv_malloc((size_t)W * H * 4);
if (!s_px) return false;
uint32_t on = 0xFF000000u | (col & 0xFFFFFFu);
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++) {
int sx = x / LOGO_SCALE, sy = y / LOGO_SCALE;
bool bit = LOGO[sy * (LOGO_W / 8) + sx / 8] & (0x80 >> (sx % 8));
s_px[y * W + x] = bit ? on : 0;
}
memset(&s_img, 0, sizeof(s_img));
s_img.header.magic = LV_IMAGE_HEADER_MAGIC;
s_img.header.cf = LV_COLOR_FORMAT_ARGB8888;
s_img.header.w = W;
s_img.header.h = H;
s_img.header.stride = W * 4;
s_img.data_size = (uint32_t)W * H * 4;
s_img.data = (const uint8_t*)s_px;
return true;
}
// One of the loading dots: rises 6 px and back, each a beat after the last.
static void dotY(void* o, int32_t v) { lv_obj_set_style_translate_y((lv_obj_t*)o, v, 0); }
static void wave(lv_obj_t* dot, int i) {
lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_var(&a, dot);
lv_anim_set_exec_cb(&a, dotY);
lv_anim_set_values(&a, 0, -6);
lv_anim_set_duration(&a, 260);
lv_anim_set_playback_duration(&a, 260);
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
lv_anim_set_repeat_delay(&a, 420);
lv_anim_set_delay(&a, 140 * i);
lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out);
lv_anim_start(&a);
}
// "v1.28-solo.1-abcdef" -> "v1.28-solo.1": build.sh appends the commit as
// the last dash segment (as ui-new's splash).
static void soloVersion(char* out, size_t n) {
const char* ver = FIRMWARE_VERSION;
const char* dash = strrchr(ver, '-');
size_t len = dash ? (size_t)(dash - ver) : strlen(ver);
if (len >= n) len = n - 1;
memcpy(out, ver, len);
out[len] = '\0';
}
} // namespace splash
void UITask::showSplash() {
using namespace splash;
if (s_root) return;
s_root = lv_obj_create(lv_layer_top());
lv_obj_remove_style_all(s_root);
lv_obj_set_size(s_root, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(s_root, lv_color_hex(theme::BG), 0);
lv_obj_set_style_bg_opa(s_root, LV_OPA_COVER, 0);
lv_obj_add_flag(s_root, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(s_root, onTap, LV_EVENT_CLICKED, NULL);
if (buildLogo(theme::ACCENT)) {
lv_obj_t* logo = lv_image_create(s_root);
lv_image_set_src(logo, &s_img);
lv_obj_align(logo, LV_ALIGN_CENTER, 0, -42);
anim::rise(logo, 10, 400);
}
lv_obj_t* solo = label(s_root, "S O L O", THEME_FONT_TITLE, theme::TEXT);
lv_obj_align(solo, LV_ALIGN_CENTER, 0, -8);
char ver[24], line[64];
soloVersion(ver, sizeof(ver));
lv_obj_t* v = label(s_root, ver[0] ? ver : "dev", THEME_FONT_BODY, theme::ACCENT);
lv_obj_align(v, LV_ALIGN_CENTER, 0, 22);
#ifdef MESHCORE_VERSION
snprintf(line, sizeof(line), "MeshCore %s - built %s", MESHCORE_VERSION, FIRMWARE_BUILD_DATE);
#else
snprintf(line, sizeof(line), "Built %s", FIRMWARE_BUILD_DATE);
#endif
lv_obj_t* up = label(s_root, line, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_align(up, LV_ALIGN_CENTER, 0, 42);
lv_obj_t* dots = lv_obj_create(s_root);
lv_obj_remove_style_all(dots);
lv_obj_set_size(dots, 60, 20);
lv_obj_align(dots, LV_ALIGN_BOTTOM_MID, 0, -18);
for (int i = 0; i < 3; i++) {
lv_obj_t* d = lv_obj_create(dots);
lv_obj_remove_style_all(d);
lv_obj_set_size(d, 7, 7);
lv_obj_set_style_radius(d, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_opa(d, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(d, lv_color_hex(theme::ACCENT), 0);
lv_obj_align(d, LV_ALIGN_BOTTOM_MID, (i - 1) * 16, -2);
wave(d, i);
}
s_timer = lv_timer_create(onTimer, SPLASH_MS, NULL); // deleted by dismiss()
}
+76 -7
View File
@@ -1,14 +1,24 @@
#pragma once
// Design tokens for ui-lvgl — the one place colours, spacing and type live.
// First colour take on the "Amber Trace" direction: amber on near-black.
// Design tokens for ui-lvgl -- the one place colours, spacing and type live --
// and the rules every widget follows (install() below).
//
// The accent (amber by default; Settings > Display & power > Accent colour)
// on near-black, soft corners. The accent means one of three things:
// - ACCENT fill, dark text: the primary action of a screen or popup
// (Download, Install, Save, Go) -- stylePrimary() in UITask.cpp;
// - ACCENT_DIM fill, light text: something selected / on (a tab, a chip, a
// segment, a mode that is running);
// - ACCENT text or icon: a name, a count, a background mode, a value.
// Everything else is BG / SURFACE / SURFACE_2 with TEXT or TEXT_MUTED.
#include <src/themes/lv_theme_private.h> // lv_theme_t's fields, to layer a theme
namespace theme {
// Colours (0xRRGGBB)
static const uint32_t BG = 0x0B0B0D; // screen background
static const uint32_t SURFACE = 0x17171B; // cards, rows, bubbles (incoming)
static const uint32_t SURFACE_2 = 0x222228; // pressed / raised
static const uint32_t ACCENT = 0xFFB000; // amber: focus, badges, own bubbles
static const uint32_t ACCENT_DIM= 0x6B4A00; // own bubble fill
static const uint32_t SURFACE = 0x1A1A1F; // cards, rows, bubbles (incoming)
static const uint32_t SURFACE_2 = 0x27272E; // pressed / raised
static uint32_t ACCENT = 0xFFB000; // primary actions, names, badges, modes (setAccent)
static uint32_t ACCENT_DIM = 0x6B4A00; // selected / on fill, own bubbles (setAccent)
static const uint32_t TEXT = 0xF2EEE6;
static const uint32_t TEXT_MUTED= 0x9A958C;
static const uint32_t OK = 0x6FCF6F; // delivered / relayed
@@ -17,7 +27,8 @@ namespace theme {
// Spacing (px)
static const int PAD = 8;
static const int GAP = 6;
static const int RADIUS = 10;
static const int RADIUS = 10; // cards, rows, buttons (pills use LV_RADIUS_CIRCLE)
static const int RADIUS_SM = 6; // small cells inside those
static const int STATUS_H = 22; // top status bar
static const int ROW_H = 44; // list row (touch target)
@@ -42,4 +53,62 @@ namespace theme {
#define UI_SYMBOL_CHART "\xEF\x82\x80" // U+F080, diagnostics
#define UI_SYMBOL_KEY "\xEF\x82\x84" // U+F084, admin
#define UI_SYMBOL_ROUTE "\xEF\x93\x97" // U+F4D7, trail
#define UI_SYMBOL_MUTE "\xEF\x9A\xA9" // U+F6A9, muted (speaker with a cross)
// Accent choices; the fill is the colour at 42% over the background.
struct Accent { const char* name; uint32_t col; };
static const Accent ACCENTS[] = {
{ "Amber", 0xFFB000 }, { "Orange", 0xFF7A2E }, { "Coral", 0xFF6B7A },
{ "Violet", 0xB18CFF }, { "Cyan", 0x3CC8E8 }, { "Lime", 0xB5E04A },
};
static const int ACCENT_COUNT = sizeof(ACCENTS) / sizeof(ACCENTS[0]);
static int s_accent = 0;
static void setAccent(int idx) {
if (idx < 0 || idx >= ACCENT_COUNT) idx = 0;
s_accent = idx;
ACCENT = ACCENTS[idx].col;
uint32_t dim = 0;
for (int sh = 0; sh <= 16; sh += 8) {
uint32_t c = (ACCENT >> sh) & 0xFF, b = (BG >> sh) & 0xFF;
dim |= ((c * 42 + b * 58) / 100) << sh;
}
ACCENT_DIM = dim;
}
// Over LVGL's default theme, for every widget: a checked button is
// "selected" (ACCENT_DIM, not the default theme's full accent), buttons
// have the card radius and give a little under the finger (the default
// theme's transitions animate it both ways).
static lv_theme_t s_theme;
static lv_style_t s_btn, s_press, s_checked;
static void applyTheme(lv_theme_t* th, lv_obj_t* obj) {
(void)th;
if (lv_obj_check_type(obj, &lv_button_class)) {
lv_obj_add_style(obj, &s_btn, 0);
lv_obj_add_style(obj, &s_press, LV_STATE_PRESSED);
lv_obj_add_style(obj, &s_checked, LV_STATE_CHECKED);
}
}
// (Re)builds the default theme in the current accent with this layer over it.
static void install(lv_display_t* disp) {
static bool styled = false;
if (!styled) {
styled = true;
lv_style_init(&s_btn);
lv_style_set_radius(&s_btn, RADIUS);
lv_style_set_shadow_width(&s_btn, 0);
lv_style_init(&s_press);
lv_style_set_transform_width(&s_press, -2);
lv_style_set_transform_height(&s_press, -2);
lv_style_init(&s_checked);
lv_style_set_text_color(&s_checked, lv_color_hex(TEXT));
}
lv_style_set_bg_color(&s_checked, lv_color_hex(ACCENT_DIM));
lv_theme_t* base = lv_theme_default_init(disp, lv_color_hex(ACCENT), lv_color_hex(ACCENT_DIM), true, THEME_FONT_BODY);
s_theme = *base;
lv_theme_set_parent(&s_theme, base);
lv_theme_set_apply_cb(&s_theme, applyTheme);
lv_display_set_theme(disp, &s_theme);
lv_obj_report_style_change(&s_checked);
}
}
+137 -33
View File
@@ -32,6 +32,7 @@ template <class T> static T* psramBuf(size_t n) {
#include "../ui-core/SoundNotifier.h"
#include "../ui-core/MessageText.h"
#include "Theme.h"
#include "Anim.h"
#include "LvglPort.h"
#include "../ui-core/KeyboardData.h"
#include "Keyboard.h"
@@ -93,7 +94,7 @@ static lv_obj_t* segmented(lv_obj_t* parent, const char** map, int sel, int w, i
lv_obj_set_style_text_color(m, lv_color_hex(theme::TEXT), LV_PART_ITEMS);
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, 8, LV_PART_ITEMS);
lv_obj_set_style_radius(m, theme::RADIUS_SM, LV_PART_ITEMS);
return m;
}
@@ -115,6 +116,14 @@ static lv_obj_t* textField(lv_obj_t* parent, const char* placeholder = NULL) {
return ta;
}
// The primary action of a screen or popup: full amber, dark 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::BG), 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;
@@ -124,7 +133,7 @@ static void badge(lv_obj_t* parent, int n, bool overflow) {
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, 10, 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);
@@ -201,15 +210,15 @@ void UITask::begin(DisplayDriver* display_drv, SensorManager* sensors, NodePrefs
Serial.println("ui-lvgl: no memory for display buffers");
return;
}
lv_theme_t* th = lv_theme_default_init(lv_display_get_default(), lv_color_hex(theme::ACCENT),
lv_color_hex(theme::ACCENT_DIM), true, THEME_FONT_BODY);
lv_display_set_theme(lv_display_get_default(), th);
theme::setAccent(lvport::loadAccent());
theme::install(lv_display_get_default());
buildStatusBar();
applyDisplayPrefs(); // a slider percentage overrides the level main.cpp set
showHome();
lvport::loadPin(_pin, sizeof(_pin));
if (_pin[0]) lockScreen(); // a reboot doesn't get round the PIN
showSplash(); // over both; fades out by itself
}
MyMesh::Listener* UITask::meshListener() { return _core; }
@@ -279,6 +288,52 @@ void UITask::loop() {
(unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM), esp_get_idf_version());
}
#endif
#if defined(UI_PERF_TEST) && defined(ESP32)
// -D UI_PERF_TEST: walks through screens by itself and prints how long each
// display refresh took during the transition that followed (render+flush).
{
static uint32_t next = 15000, sum_us = 0, max_us = 0, frames = 0, t0 = 0;
static int step = 0;
static const char* name = "";
static bool hooked = false;
if (!hooked) {
hooked = true;
lv_display_add_event_cb(lv_display_get_default(), [](lv_event_t* e) {
if (lv_event_get_code(e) == LV_EVENT_REFR_START) t0 = micros();
else { uint32_t d = micros() - t0; sum_us += d; if (d > max_us) max_us = d; frames++; }
}, LV_EVENT_REFR_START, NULL);
lv_display_add_event_cb(lv_display_get_default(), [](lv_event_t* e) {
uint32_t d = micros() - t0; sum_us += d; if (d > max_us) max_us = d; frames++; (void)e;
}, LV_EVENT_REFR_READY, NULL);
}
if (millis() >= next) {
if (*name) Serial.printf("PERF %-10s frames %2lu avg %5.1f ms max %5.1f ms\n", name, (unsigned long)frames,
frames ? sum_us / 1000.0f / frames : 0.0f, max_us / 1000.0f);
if (*name) Serial.printf("PERF flush %5.1f ms per frame\n", frames ? lvport::s_flush_us / 1000.0f / frames : 0.0f);
sum_us = max_us = frames = 0;
lvport::s_flush_us = 0;
lv_display_trigger_activity(NULL); // no sleeping mid-test
next = millis() + 1200;
if (step == 0) unlockScreen(); // a PIN lock would be drawn over everything
if (step % 8 == 0 && step > 0) { // a static full-screen redraw of Home, for the baseline
uint32_t fl0 = lvport::s_flush_us, t = micros();
for (int i = 0; i < 10; i++) { lv_obj_invalidate(lv_screen_active()); lv_refr_now(NULL); }
Serial.printf("PERF full redraw (Home) %5.1f ms, flush %5.1f ms\n", (micros() - t) / 10000.0f,
(lvport::s_flush_us - fl0) / 10000.0f);
}
switch (step++ % 8) {
case 0: name = "settings"; showSettings(); break;
case 1: name = "back-home"; back(); break;
case 2: name = "messages"; showChats(); break;
case 3: name = "back-home"; back(); break;
case 4: name = "nearby"; showNearby(); break;
case 5: name = "back-home"; back(); break;
case 6: name = "page-2"; setHomePage(1); break;
case 7: name = "page-1"; setHomePage(0); break;
}
}
}
#endif
#ifdef PIN_BUZZER
_buzzer.loop();
if (soundctl::autoTick(_prefs, _buzzer, isClientConnected()) && !_asleep) refreshStatusBar();
@@ -484,7 +539,7 @@ void UITask::buildStatusBar() {
lv_obj_set_size(_status_icons, 200, LV_PCT(100)); // fixed: packed to its right edge
lv_obj_set_flex_flow(_status_icons, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(_status_icons, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(_status_icons, 7, 0);
lv_obj_set_style_pad_column(_status_icons, 3, 0);
_toast = lv_obj_create(lv_layer_top());
lv_obj_remove_flag(_toast, LV_OBJ_FLAG_SCROLLABLE);
@@ -544,7 +599,7 @@ void UITask::refreshStatusBar() {
if (_core->gpsEnabled() || fix) icons[n++] = { LV_SYMBOL_GPS, fix ? theme::OK : theme::TEXT_MUTED };
if (_prefs && _prefs->alarm_on) icons[n++] = { LV_SYMBOL_BELL, theme::TEXT_MUTED };
#ifdef PIN_BUZZER
if (_buzzer.isQuiet()) icons[n++] = { LV_SYMBOL_MUTE, theme::TEXT_MUTED };
if (_buzzer.isQuiet()) icons[n++] = { UI_SYMBOL_MUTE, theme::TEXT_MUTED };
#endif
if (_prefs && _prefs->advert_auto_interval_sec > 0) icons[n++] = { UI_SYMBOL_RADIO, theme::ACCENT };
if (_core->trail.isActive()) icons[n++] = { UI_SYMBOL_ROUTE, theme::ACCENT };
@@ -558,7 +613,11 @@ void UITask::refreshStatusBar() {
if (strcmp(sig, _status_sig) != 0) { // rebuilt only when something changed
strcpy(_status_sig, sig);
lv_obj_clean(_status_icons);
for (int i = n - 1; i >= 0; i--) label(_status_icons, icons[i].sym, THEME_FONT_SMALL, icons[i].col);
for (int i = n - 1; i >= 0; i--) { // one cell each: glyphs vary in width, the rhythm doesn't
lv_obj_t* l = label(_status_icons, icons[i].sym, THEME_FONT_SMALL, icons[i].col);
lv_obj_set_width(l, 16);
lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, 0);
}
}
lv_obj_align_to(_status_icons, _status_batt, LV_ALIGN_OUT_LEFT_MID, -8, 0);
}
@@ -581,14 +640,18 @@ bool UITask::ensureGps() {
// One persistent timer, paused between toasts: hides the toast when it fires.
static void toastTimerCb(lv_timer_t* t) {
lv_obj_add_flag((lv_obj_t*)lv_timer_get_user_data(t), LV_OBJ_FLAG_HIDDEN);
anim::fadeHide((lv_obj_t*)lv_timer_get_user_data(t));
lv_timer_pause(t);
}
void UITask::showToast(const char* text, uint32_t ms) {
if (!_toast) return;
lv_label_set_text(lv_obj_get_child(_toast, 0), text);
bool shown = !lv_obj_has_flag(_toast, LV_OBJ_FLAG_HIDDEN);
lv_anim_delete(_toast, NULL); // a fade-out in progress
lv_obj_set_style_opa(_toast, LV_OPA_COVER, 0);
lv_obj_remove_flag(_toast, LV_OBJ_FLAG_HIDDEN);
if (!shown) anim::rise(_toast, 12); // a replaced text just changes
if (!_toast_timer) _toast_timer = lv_timer_create(toastTimerCb, ms, _toast);
lv_timer_set_period(_toast_timer, ms);
lv_timer_reset(_toast_timer);
@@ -618,7 +681,7 @@ lv_obj_t* UITask::newScreen(const char* title, bool with_back) {
_wifi_scanning = false;
_ota_status = _ota_bar = _ota_btn = _ota_btn_lbl = nullptr;
for (lv_obj_t*& t : _map_tiles) t = nullptr;
lv_obj_t* prev = lv_screen_active();
lv_obj_t* prev = _scr;
lv_obj_t* scr = lv_obj_create(NULL);
styleSurface(scr, theme::BG);
lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
@@ -655,12 +718,35 @@ lv_obj_t* UITask::newScreen(const char* title, bool with_back) {
lv_obj_set_style_pad_all(body, theme::PAD, 0);
lv_obj_set_style_pad_row(body, theme::GAP, 0);
_body = body;
// The new screen emerges from the middle (Anim.h); a screen rebuilt in
// place (same screen, same title) swaps without motion.
bool same = _screen == _shown_screen && strncmp(title ? title : "", _shown_title, sizeof(_shown_title) - 1) == 0;
bool backward = _nav_back || screenDepth(_screen) < screenDepth(_shown_screen);
_nav_back = false;
_shown_screen = _screen;
snprintf(_shown_title, sizeof(_shown_title), "%s", title ? title : "");
_scr = scr;
lv_screen_load(scr);
if (prev && prev != scr) lv_obj_delete_async(prev);
if (prev) lv_obj_delete_async(prev); // this usually runs from one of its own widgets
if (prev && !same && !_asleep) anim::screenIn(scr, body, backward);
return body;
}
// How deep a screen sits below Home, for the slide direction.
int UITask::screenDepth(Screen s) {
switch (s) {
case SCR_HOME: return 0;
case SCR_CHATS: case SCR_NEARBY: case SCR_MAP: case SCR_SETTINGS: case SCR_FAVS:
case SCR_COMPASS: case SCR_CLOCK: case SCR_BOT: case SCR_REPEATER: case SCR_ADMIN_PICK:
case SCR_DIAG: return 1;
case SCR_THREAD: case SCR_CONTACTS: case SCR_CHANNEL_EDIT: case SCR_NODE:
case SCR_SETTINGS_NAV: case SCR_ADMIN: return 2;
default: return 3; // pages under a settings page
}
}
void UITask::back() {
_nav_back = true; // the screen shown from here slides back
switch (_screen) {
case SCR_THREAD: if (_nav_overlay) navClosePopup(); else showChats(); break;
case SCR_CONTACTS: showChats(); break;
@@ -721,9 +807,10 @@ void UITask::back() {
else if (!_map_nav) showNearby(); // the Nodes map opens from Nearby
else showHome();
break;
case SCR_WIFI: if (_wifi_from_map) showMap(); else showSettings(); break;
case SCR_WIFI: showSettings(); break;
default: break;
}
_nav_back = false; // only closed a popup
}
// ── Home ──────────────────────────────────────────────────────────────────────
@@ -868,6 +955,7 @@ void UITask::homeSwipePoll() {
void UITask::setHomePage(int page) {
if (_screen != SCR_HOME || !home::s_row) return; // s_row went with an older screen
if (page < 0 || page >= home::PAGES) return;
int from = home::s_page;
home::s_page = page;
lv_obj_clean(home::s_row);
_home_unread = nullptr;
@@ -884,6 +972,7 @@ void UITask::setHomePage(int page) {
}
for (int i = 0; home::s_dots && i < (int)lv_obj_get_child_count(home::s_dots); i++)
lv_obj_set_style_bg_color(lv_obj_get_child(home::s_dots, i), lv_color_hex(i == page ? theme::ACCENT : theme::SURFACE_2), 0);
if (page != from) anim::slideIn(home::s_row, page > from ? 48 : -48);
refreshHome();
}
@@ -979,7 +1068,7 @@ static void sectionWithFilter(lv_obj_t* parent, const char* text, bool fav_only,
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, 12, 0);
lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(fav_only ? theme::ACCENT_DIM : theme::SURFACE), 0);
lv_obj_align(b, LV_ALIGN_RIGHT_MID, -2, 0);
@@ -1015,7 +1104,7 @@ void UITask::buildChats() {
ch_rows++;
char title[48], sub[64] = "";
snprintf(title, sizeof(title), "%s%s%s", fav ? UI_SYMBOL_STAR " " : "", ch.name,
chanctl::notif(_prefs, i) == chanctl::NOTIF_MUTED ? " " LV_SYMBOL_MUTE : "");
chanctl::notif(_prefs, i) == chanctl::NOTIF_MUTED ? " " UI_SYMBOL_MUTE : "");
int n = _core->history.histCountForChannel(i);
if (n > 0) {
const ChHistEntry& e = _core->history.chAtPos(_core->history.histEntryForChannel(i, 0));
@@ -1052,7 +1141,7 @@ void UITask::buildChats() {
char name[48];
contactName(e.prefix, name, sizeof(name));
if (known && contactctl::favourite(c)) { char t[48]; snprintf(t, sizeof(t), UI_SYMBOL_STAR " %s", name); strcpy(name, t); }
if (known && contactctl::notif(_prefs, c.id.pub_key) == contactctl::NOTIF_MUTED) strncat(name, " " LV_SYMBOL_MUTE, sizeof(name) - strlen(name) - 1);
if (known && contactctl::notif(_prefs, c.id.pub_key) == contactctl::NOTIF_MUTED) strncat(name, " " UI_SYMBOL_MUTE, sizeof(name) - strlen(name) - 1);
char sub[64];
snprintf(sub, sizeof(sub), "%s%s", e.outgoing ? "Me: " : "", e.text);
lv_obj_t* row = listRow(body, name, sub, onOpenDMRow, (void*)(uintptr_t)rows);
@@ -1101,6 +1190,7 @@ void UITask::buildChats() {
lv_obj_set_style_shadow_width(add, 0, 0);
lv_obj_add_event_cb(add, onNewChat, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(add, LV_SYMBOL_PLUS " New message", THEME_FONT_BODY, theme::TEXT));
stylePrimary(add);
}
// ── Contact picker (start a DM) ───────────────────────────────────────────────
@@ -1192,12 +1282,12 @@ void UITask::buildNearby() {
lv_obj_set_height(c, 26);
lv_obj_set_flex_grow(c, 1);
lv_obj_set_style_pad_all(c, 0, 0);
lv_obj_set_style_radius(c, 13, 0);
lv_obj_set_style_radius(c, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_shadow_width(c, 0, 0);
bool on = f == _nearby->filter();
lv_obj_set_style_bg_color(c, lv_color_hex(on ? theme::ACCENT : theme::SURFACE), 0);
lv_obj_set_style_bg_color(c, lv_color_hex(on ? theme::ACCENT_DIM : theme::SURFACE), 0); // selected (Theme.h)
lv_obj_add_event_cb(c, onNearbyChip, LV_EVENT_CLICKED, (void*)(uintptr_t)f);
lv_obj_center(label(c, NearbyModel::filterLabel(f), THEME_FONT_SMALL, on ? theme::BG : theme::TEXT));
lv_obj_center(label(c, NearbyModel::filterLabel(f), THEME_FONT_SMALL, theme::TEXT));
}
_nearby_status = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
@@ -1238,7 +1328,7 @@ void UITask::startNearbyScan() {
// Dimmed full-screen overlay (swallows taps) holding a panel with the results.
// A child of the current screen, so it goes away with it.
void UITask::showScanPopup() {
_scan_overlay = lv_obj_create(lv_screen_active());
_scan_overlay = lv_obj_create(screen());
lv_obj_remove_style_all(_scan_overlay);
lv_obj_set_size(_scan_overlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(_scan_overlay, lv_color_hex(0x000000), 0);
@@ -1247,6 +1337,7 @@ void UITask::showScanPopup() {
lv_obj_remove_flag(_scan_overlay, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* panel = lv_obj_create(_scan_overlay);
anim::popup(_scan_overlay);
lv_obj_set_size(panel, lv_display_get_horizontal_resolution(NULL) - 16,
lv_display_get_vertical_resolution(NULL) - theme::STATUS_H - 12);
lv_obj_set_pos(panel, 8, theme::STATUS_H + 6);
@@ -1757,7 +1848,7 @@ void UITask::buildThread() {
if (_compose_ta && _share_text[0]) { // shareToMessage(): the text waits in the field
lv_textarea_set_text(_compose_ta, _share_text);
_share_text[0] = '\0';
lv_obj_update_layout(lv_screen_active());
lv_obj_update_layout(screen());
setKeyboardVisible(true);
}
}
@@ -1814,7 +1905,7 @@ static void msgLocButton(lv_obj_t* parent, const char* text, int idx, bool save,
lv_obj_set_size(b, LV_SIZE_CONTENT, 28);
lv_obj_set_style_pad_hor(b, 10, 0);
lv_obj_set_style_pad_ver(b, 0, 0);
lv_obj_set_style_radius(b, 14, 0);
lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(accent ? theme::ACCENT : theme::SURFACE_2), 0);
lv_obj_add_event_cb(b, onMsgLoc, LV_EVENT_CLICKED, (void*)(uintptr_t)((idx << 1) | (save ? 1 : 0)));
@@ -1891,12 +1982,15 @@ static void bubble(lv_obj_t* list, const char* from, const char* text, bool own,
msgLocButton(acts, UI_SYMBOL_FLAG " Save", loc_idx, true, false);
}
if (!meta_in_header) {
lv_obj_t* m = label(b, meta, THEME_FONT_SMALL, theme::TEXT_MUTED);
if (status) {
lv_label_set_text_fmt(m, "%s %s", meta, status);
lv_obj_set_style_text_color(m, lv_color_hex(status_col), 0);
}
if (!meta_in_header) { // the age, then the delivery mark in its colour (as L1: ✓ 3)
lv_obj_t* line = lv_obj_create(b);
lv_obj_remove_style_all(line);
lv_obj_remove_flag(line, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(line, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(line, LV_FLEX_FLOW_ROW);
lv_obj_set_style_pad_column(line, 6, 0);
label(line, meta, THEME_FONT_SMALL, theme::TEXT_MUTED);
if (status && status[0]) label(line, status, THEME_FONT_SMALL, status_col);
}
}
@@ -1955,9 +2049,16 @@ void UITask::refreshThread() {
body = sep + 2;
}
bool own = strcmp(from, "Me") == 0;
// Own posts: a check once a repeater echoed it, with how many distinct
// repeaters did (markChannelRelayed); nothing before -- no echo is normal.
const char* st = NULL; uint32_t col = theme::TEXT_MUTED;
if (own && e.relay_status == ACK_OK) { st = LV_SYMBOL_OK " relayed"; col = theme::OK; }
else if (own && e.relay_status == ACK_PENDING) st = "sent";
char relays[16];
if (own && e.relay_status == ACK_OK) {
int nrel = e.path_len & 63;
if (nrel > 0) snprintf(relays, sizeof(relays), LV_SYMBOL_OK " %d", nrel);
else snprintf(relays, sizeof(relays), LV_SYMBOL_OK);
st = relays; col = theme::OK;
} else if (own) st = "";
int loc = own ? -1 : noteMsgLocation(body, from);
bubble(_thread_list, own ? NULL : from, body, own, e.timestamp, st, col,
loc, noteMsgMeta(pos, true, own, loc, own ? "" : from));
@@ -1973,10 +2074,10 @@ void UITask::refreshThread() {
const char* st = NULL; uint32_t col = theme::TEXT_MUTED;
if (e.outgoing) {
switch (h.dmEffectiveStatus(e)) {
case ACK_OK: st = LV_SYMBOL_OK " delivered"; col = theme::OK; break;
case ACK_FAIL: st = LV_SYMBOL_CLOSE " not delivered"; col = theme::FAIL; break;
case ACK_PENDING: st = "sending..."; break;
default: st = "sent"; break;
case ACK_OK: st = LV_SYMBOL_OK; col = theme::OK; break;
case ACK_FAIL: st = LV_SYMBOL_CLOSE; col = theme::FAIL; break;
case ACK_PENDING: st = "..."; break;
default: st = ""; break;
}
}
// A room post is filed "Author: text": shown under its author.
@@ -2169,6 +2270,8 @@ void UITask::buildSchemaSettings() {
sectionTitle(body, "SECURITY");
listRow(body, LV_SYMBOL_EYE_CLOSE " Screen PIN", _pin[0] ? "On - asked when the screen wakes" : "Off",
onPinSetup, NULL);
sectionTitle(body, "LOOK");
accentRow(body); // DeviceScreen.h
}
if (_settings_page == settings::PG_SOUND) buildSoundRows(body, false); // the melodies
if (_settings_page == settings::PG_MESSAGES) {
@@ -2357,3 +2460,4 @@ void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) {
#include "SoundScreen.h"
#include "QuickScreen.h"
#include "OtaScreen.h"
#include "Splash.h"
+13 -2
View File
@@ -89,7 +89,7 @@ public:
void navCoordsDone(bool ok);
void shareToMessage(const char* text);
void messageLocationAction(int idx, bool save);
void showWifi(bool from_map);
void showWifi();
void wifiSetAllowed(bool on);
void wifiScan();
void wifiPick(int idx);
@@ -214,6 +214,8 @@ public:
void pinSetupKey(const char* key);
void pinRemove();
void pinRowRefresh();
void accentRow(lv_obj_t* body); // Settings > Display & power > Accent colour
void setAccent(int idx);
void showFavourites();
void showDiag();
void diagTab(int tab);
@@ -239,6 +241,10 @@ private:
void buildStatusBar();
void refreshStatusBar();
lv_obj_t* newScreen(const char* title, bool with_back);
void showSplash(); // Splash.h
// The screen being shown: newScreen()'s, already while it slides in (popups
// and keyboards go on it, not on the one sliding out).
lv_obj_t* screen() const { return _scr ? _scr : lv_screen_active(); }
void buildHome();
void refreshHome();
void homeSwipePoll();
@@ -355,6 +361,12 @@ private:
UiCore* _core = nullptr;
Screen _screen = SCR_HOME;
// What newScreen() last loaded, for the slide direction (Anim.h)
Screen _shown_screen = SCR_HOME;
char _shown_title[32] = "";
bool _nav_back = false; // set by back() around the screen it shows
lv_obj_t* _scr = nullptr; // the screen newScreen() built (active once its slide starts)
static int screenDepth(Screen s);
bool _asleep = false;
uint32_t _next_status_ms = 0;
uint32_t _next_thread_check_ms = 0;
@@ -473,7 +485,6 @@ private:
char _share_text[96] = ""; // waiting for a conversation to be picked (shareToMessage)
// WiFi settings (SCR_WIFI)
bool _wifi_from_map = false;
bool _wifi_scanning = false;
lv_obj_t* _wifi_ssid = nullptr;
lv_obj_t* _wifi_pass = nullptr;
@@ -28,8 +28,7 @@ static lv_obj_t* wifiField(lv_obj_t* parent, const char* placeholder, bool passw
return ta;
}
void UITask::showWifi(bool from_map) {
_wifi_from_map = from_map;
void UITask::showWifi() {
_screen = SCR_WIFI;
buildWifi();
}
@@ -75,6 +74,7 @@ void UITask::buildWifi() {
lv_obj_set_style_bg_color(save, lv_color_hex(theme::ACCENT_DIM), 0);
lv_obj_add_event_cb(save, onWifiSave, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(save, LV_SYMBOL_OK " Save", THEME_FONT_BODY, theme::TEXT));
stylePrimary(save);
_wifi_status = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
char ssid[33], pass[65];
@@ -84,7 +84,7 @@ void UITask::buildWifi() {
// Keyboard over the bottom of the screen; the body shrinks above it while
// it is up, so the field being edited stays visible.
_wifi_kb = kb::create(lv_screen_active(), _prefs);
_wifi_kb = kb::create(screen(), _prefs);
lv_obj_set_size(_wifi_kb, LV_PCT(100), 124);
lv_obj_align(_wifi_kb, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_add_event_cb(_wifi_kb, onWifiKb, LV_EVENT_READY, NULL);
@@ -102,7 +102,7 @@ void UITask::wifiEdit(lv_obj_t* ta) {
lv_obj_remove_flag(_wifi_kb, LV_OBJ_FLAG_HIDDEN);
if (_body) lv_obj_set_height(_body, lv_obj_get_height(_body) - lv_obj_get_height(_wifi_kb));
}
lv_obj_update_layout(lv_screen_active());
lv_obj_update_layout(screen());
lv_obj_scroll_to_view(ta, LV_ANIM_OFF);
}
@@ -129,7 +129,7 @@ static void wifiRow(lv_obj_t* body) {
lv_obj_t* row = lv_obj_get_parent(sw);
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
if (lv_event_get_target(e) == lv_event_get_current_target(e)) s_ui->showWifi(false); // not the switch
if (lv_event_get_target(e) == lv_event_get_current_target(e)) s_ui->showWifi(); // not the switch
}, LV_EVENT_CLICKED, NULL);
}
@@ -168,7 +168,7 @@ void UITask::pollWifiScan() {
lv_obj_set_style_pad_hor(b, 10, 0);
lv_obj_set_style_pad_ver(b, 0, 0);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_radius(b, 15, 0);
lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE), 0);
lv_obj_add_event_cb(b, onWifiPick, LV_EVENT_CLICKED, (void*)(uintptr_t)i);
lv_obj_center(label(b, s_wifi_names[i], THEME_FONT_SMALL, theme::TEXT));
@@ -192,6 +192,5 @@ void UITask::wifiSave() {
lvport::saveWifi(ssid, pass);
wifiKeyboardHide();
showToast("WiFi saved");
if (_wifi_from_map) { showMap(); mapDownloadPopup(); }
else lv_label_set_text(_wifi_status, "Saved.");
lv_label_set_text(_wifi_status, "Saved.");
}
@@ -30,12 +30,13 @@ TEXT="$TEXT,0x2010-0x2027,0x2030,0x2039-0x203A,0x20A4-0x20BF,0x2116,0x2122"
# 62073 = map, 61632 = users (home tiles), 61774 = compass (Navigate),
# 61476 = flag (waypoint), 62405 = map marker (mark here), 61463 = clock,
# 62194 = stopwatch, 62745 = broadcast tower (radio), 61568 = bar chart (diagnostics),
# 61572 = key (admin), 62679 = route (trail in the status bar)
SYMS="61572,62679,61568,62745,61463,62194,61612,61445,62073,61632,61774,61476,62405,61441,61448,61451,61452,61453,61457,61459,61461,61465,61468,61473,61478,61479,61480,61502,61507,61512,61515,61516,61517,61521,61522,61523,61524,61543,61544,61550,61552,61553,61556,61559,61560,61561,61563,61587,61589,61636,61637,61639,61641,61664,61671,61674,61683,61724,61732,61787,61931,62016,62017,62018,62019,62020,62087,62099,62212,62189,62810,63426,63650"
# 61572 = key (admin), 62679 = route (trail in the status bar),
# 63145 = volume-mute (muted, in the status bar)
SYMS="63145,61572,62679,61568,62745,61463,62194,61612,61445,62073,61632,61774,61476,62405,61441,61448,61451,61452,61453,61457,61459,61461,61465,61468,61473,61478,61479,61480,61502,61507,61512,61515,61516,61517,61521,61522,61523,61524,61543,61544,61550,61552,61553,61556,61559,61560,61561,61563,61587,61589,61636,61637,61639,61641,61664,61671,61674,61683,61724,61732,61787,61931,62016,62017,62018,62019,62020,62087,62099,62212,62189,62810,63426,63650"
CONV="npx -y lv_font_conv@1.5.3"
gen() { # size weight ranges name
$CONV --bpp 4 --size "$1" --force-fast-kern-format \
$CONV --bpp 4 --size "$1" --no-compress --force-fast-kern-format \
--font "$CACHE/NotoSans-$2.ttf" -r "$3" \
--font "$CACHE/fa.woff" -r "$SYMS" \
--format lvgl --lv-include lvgl.h --lv-font-name "$4" -o "$HERE/$4.c"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -33,7 +33,7 @@
LV_FONT_DECLARE(ui_font_16) LV_FONT_DECLARE(ui_font_20) \
LV_FONT_DECLARE(ui_font_40)
#define LV_FONT_DEFAULT &ui_font_14
#define LV_USE_FONT_COMPRESSED 1 // generated fonts are RLE-compressed (without this every glyph draws as a box)
#define LV_USE_FONT_COMPRESSED 0 // fonts are generated uncompressed: no per-glyph decompression on every draw
// PNG decoder for raster map tiles (map/TileProvider.h calls lodepng directly).
#define LV_USE_LODEPNG 1