perf(l2): cheaper frames -- less overdraw and blending

Measured on the L2 (steady full frames): Settings page 37.7 -> 31.4 ms,
Display 41.5 -> 37.9, Contacts 34.6 -> 32.9, Home 37.3 -> 32.4.

- styleSurface() leaves boxes in the page colour unpainted; styleOpaque()
  for the screen and the status bar, which things slide under.
- group() cards clip their corners only while a row is held
  (rowPressable(), shared by the tappable rows).
- rowSlider(): the track an opaque tint (theme::mix(), also behind
  ACCENT_DIM) instead of 20 % accent blended every frame.
- Map buttons, pills, scale, nav bar and the Home minimap labels opaque.
- Screens drift in without the fading cover: its full-screen blend was in
  every entrance frame (page 39 -> 29 ms).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-30 14:30:45 +02:00
co-authored by Claude Opus 5.5
parent f29c280c34
commit abbbdc5ae5
8 changed files with 72 additions and 57 deletions
+6 -12
View File
@@ -47,19 +47,13 @@ namespace anim {
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.
// A fading cover's end: it goes.
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, LV_PART_MAIN), 0);
run(cover, setBgOpa, LV_OPA_COVER, LV_OPA_TRANSP, SCREEN_MS, coverDone);
// A new screen emerges: its content drifts a few pixels into place (up
// going deeper, down coming back). No fade: a cover fading over the screen
// blended a full-screen fill into every frame of it (~7 ms each on the L2).
static void screenIn(lv_obj_t* body, bool back) {
if (body) run(body, setTy, back ? -6 : 6, 0, SCREEN_MS);
}
+1 -3
View File
@@ -124,9 +124,7 @@ void UITask::buildBot() {
lv_obj_set_width(v, botcfg::isHour(r.kind) ? 60 : 150); // fixed: LONG_DOT needs a width
lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0);
label(row, LV_SYMBOL_RIGHT, THEME_FONT_SMALL, theme::TEXT_MUTED);
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);
rowPressable(row);
lv_obj_add_event_cb(row, onBotRow, LV_EVENT_CLICKED, (void*)(uintptr_t)i);
}
}
@@ -600,7 +600,7 @@ void UITask::buildHomeMap(lv_obj_t* box) {
s_fitted = false;
s_hint = label(s_area, "", THEME_FONT_SMALL, theme::TEXT);
lv_obj_set_style_bg_color(s_hint, lv_color_hex(theme::BG), 0);
lv_obj_set_style_bg_opa(s_hint, LV_OPA_80, 0);
lv_obj_set_style_bg_opa(s_hint, LV_OPA_COVER, 0); // opaque over the tiles, as the map's pills
lv_obj_set_style_radius(s_hint, theme::RADIUS_SM, 0);
lv_obj_set_style_pad_hor(s_hint, 8, 0);
lv_obj_set_style_pad_ver(s_hint, 4, 0);
@@ -609,7 +609,7 @@ void UITask::buildHomeMap(lv_obj_t* box) {
lv_obj_add_flag(s_hint, LV_OBJ_FLAG_HIDDEN);
s_caption = label(s_area, "", THEME_FONT_SMALL, theme::TEXT); // who's on it
lv_obj_set_style_bg_color(s_caption, lv_color_hex(theme::BG), 0);
lv_obj_set_style_bg_opa(s_caption, LV_OPA_80, 0);
lv_obj_set_style_bg_opa(s_caption, LV_OPA_COVER, 0);
lv_obj_set_style_radius(s_caption, theme::RADIUS_SM, 0);
lv_obj_set_style_pad_hor(s_caption, 6, 0);
lv_obj_set_style_pad_ver(s_caption, 2, 0);
+4 -3
View File
@@ -259,7 +259,7 @@ static lv_obj_t* mapButton(lv_obj_t* parent, const char* text, lv_event_cb_t cb)
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(theme::BG), 0);
lv_obj_set_style_bg_opa(b, LV_OPA_80, 0);
lv_obj_set_style_bg_opa(b, LV_OPA_COVER, 0); // opaque: blended over the tiles, each cost ~1.6 ms a frame
lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED);
lv_obj_set_style_border_color(b, lv_color_hex(theme::SURFACE_2), 0);
lv_obj_set_style_border_width(b, 1, 0);
@@ -271,7 +271,7 @@ static lv_obj_t* mapButton(lv_obj_t* parent, const char* text, lv_event_cb_t cb)
static lv_obj_t* mapPill(lv_obj_t* parent, const char* text) {
lv_obj_t* l = label(parent, text, THEME_FONT_SMALL, theme::TEXT);
lv_obj_set_style_bg_color(l, lv_color_hex(theme::BG), 0);
lv_obj_set_style_bg_opa(l, LV_OPA_70, 0);
lv_obj_set_style_bg_opa(l, LV_OPA_COVER, 0);
lv_obj_set_style_pad_hor(l, 5, 0);
lv_obj_set_style_pad_ver(l, 1, 0);
lv_obj_set_style_radius(l, 4, 0);
@@ -344,6 +344,7 @@ void UITask::buildMap() {
lv_obj_set_style_pad_all(body, 0, 0);
lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_bg_color(body, lv_color_hex(0x1A1A1E), 0); // unloaded / missing tiles
lv_obj_set_style_bg_opa(body, LV_OPA_COVER, 0);
lv_obj_add_event_cb(body, mapview::drawGrid, LV_EVENT_DRAW_MAIN_END, &mapview::s_grid);
lv_obj_add_flag(body, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(body, onMapPress, LV_EVENT_PRESSED, NULL);
@@ -426,7 +427,7 @@ void UITask::buildMap() {
lv_obj_remove_flag(sc, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(sc, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_bg_color(sc, lv_color_hex(theme::BG), 0);
lv_obj_set_style_bg_opa(sc, LV_OPA_70, 0);
lv_obj_set_style_bg_opa(sc, LV_OPA_COVER, 0);
lv_obj_set_style_radius(sc, 4, 0);
lv_obj_set_style_pad_hor(sc, 5, 0);
lv_obj_set_style_pad_ver(sc, 3, 0);
+1 -1
View File
@@ -167,7 +167,7 @@ void UITask::buildNavControls(lv_obj_t* body) {
lv_obj_set_style_radius(_nav_bar, 0, 0);
lv_obj_set_style_shadow_width(_nav_bar, 0, 0);
lv_obj_set_style_bg_color(_nav_bar, lv_color_hex(theme::BG), 0);
lv_obj_set_style_bg_opa(_nav_bar, LV_OPA_90, 0);
lv_obj_set_style_bg_opa(_nav_bar, LV_OPA_COVER, 0); // opaque, as the map buttons
lv_obj_set_style_bg_color(_nav_bar, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED);
lv_obj_set_style_border_side(_nav_bar, LV_BORDER_SIDE_TOP, 0);
lv_obj_set_style_border_color(_nav_bar, lv_color_hex(theme::SURFACE_2), 0);
+11 -6
View File
@@ -66,16 +66,21 @@ namespace theme {
};
static const int ACCENT_COUNT = sizeof(ACCENTS) / sizeof(ACCENTS[0]);
static int s_accent = 0;
// `fg` at `pct` % over `bg`, worked out once: an opaque fill in the colour
// a translucent one would blend to, without blending it every frame.
static uint32_t mix(uint32_t fg, uint32_t bg, uint32_t pct) {
uint32_t out = 0;
for (int sh = 0; sh <= 16; sh += 8) {
uint32_t c = (fg >> sh) & 0xFF, b = (bg >> sh) & 0xFF;
out |= ((c * pct + b * (100 - pct)) / 100) << sh;
}
return out;
}
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;
ACCENT_DIM = mix(ACCENT, BG, 42);
}
// Over LVGL's default theme, for every widget: a checked button is
+46 -27
View File
@@ -119,13 +119,21 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_get_volume() { return s_ui ? (int
// ── 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, LV_OPA_COVER, 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 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);
@@ -341,11 +349,26 @@ 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_set_style_clip_corner(c, true, 0);
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);
@@ -362,11 +385,7 @@ static lv_obj_t* groupLine(lv_obj_t* card, bool tappable) {
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) {
lv_obj_add_flag(r, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_bg_color(r, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED);
lv_obj_set_style_bg_opa(r, LV_OPA_COVER, LV_STATE_PRESSED);
}
if (tappable) rowPressable(r);
return r;
}
@@ -423,6 +442,20 @@ static lv_obj_t* settingRow(lv_obj_t* parent, const char* text, const char* hint
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,
@@ -1458,7 +1491,7 @@ void UITask::buildStatusBar() {
lv_obj_set_style_text_align(tl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_add_flag(_toast, LV_OBJ_FLAG_HIDDEN);
lv_obj_t* bar = lv_obj_create(lv_layer_top());
styleSurface(bar, theme::BG);
styleOpaque(bar, theme::BG);
lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_remove_flag(bar, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(bar, LV_PCT(100), theme::STATUS_H);
@@ -1788,7 +1821,7 @@ lv_obj_t* UITask::newScreen(const char* title, bool with_back) {
for (lv_obj_t*& t : _map_tiles) t = nullptr;
lv_obj_t* prev = _scr;
lv_obj_t* scr = lv_obj_create(NULL);
styleSurface(scr, theme::BG);
styleOpaque(scr, theme::BG);
lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
int top = theme::STATUS_H;
@@ -1839,7 +1872,7 @@ lv_obj_t* UITask::newScreen(const char* title, bool with_back) {
} else {
lv_screen_load(scr);
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);
if (prev && !same && !_asleep) anim::screenIn(body, backward);
}
_fade_next = false;
return body;
@@ -3568,26 +3601,14 @@ void UITask::schemaRow(lv_obj_t* card, int i) {
const settings::Setting& st = settings::ALL[i];
uint8_t v = settings::get(*_prefs, st);
if (st.offset == offsetof(NodePrefs, buzzer_volume)) { // a five-step slider, heard on release
lv_obj_t* row = settingRow(card, st.label, NULL);
lv_obj_t* sl = lv_slider_create(row);
lv_slider_set_range(sl, 0, 4);
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_t* sl = rowSlider(settingRow(card, st.label, NULL), 0, 4, v);
lv_obj_add_event_cb(sl, onVolumeSlider, LV_EVENT_RELEASED, NULL);
return;
}
if (st.offset == offsetof(NodePrefs, display_brightness)) { // a slider here instead of five steps
lv_obj_t* row = settingRow(card, st.label, NULL);
lv_obj_t* sl = lv_slider_create(row);
lv_slider_set_range(sl, 5, 100);
uint8_t pct = _prefs->display_brightness_pct ? _prefs->display_brightness_pct
: (uint8_t)(_prefs->display_brightness * 25 > 5 ? _prefs->display_brightness * 25 : 5);
lv_slider_set_value(sl, pct, 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_t* sl = rowSlider(settingRow(card, st.label, NULL), 5, 100, pct);
lv_obj_add_event_cb(sl, onBrightnessSlider, LV_EVENT_VALUE_CHANGED, NULL);
lv_obj_add_event_cb(sl, onBrightnessSlider, LV_EVENT_RELEASED, NULL);
return;
@@ -3692,9 +3713,7 @@ void UITask::settingsGroup(int i) {
if (_core->gpsEnabled()) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, onGpsSwitch, LV_EVENT_VALUE_CHANGED, NULL);
lv_obj_t* row = lv_obj_get_parent(sw);
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);
rowPressable(row);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
if (lv_event_get_target(e) == lv_event_get_current_target(e)) onOpenGpsFromSettings(e); // not the switch
}, LV_EVENT_CLICKED, NULL);
@@ -181,9 +181,7 @@ static void wifiRow(lv_obj_t* body) {
if (lvport::wifiAllowed()) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, onWifiAllowed, LV_EVENT_VALUE_CHANGED, NULL);
lv_obj_t* row = lv_obj_get_parent(sw);
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);
rowPressable(row);
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(); // not the switch
}, LV_EVENT_CLICKED, NULL);