feat(ui-lvgl): title bar with the clock, tidier controls, contextual nav bar

Density from less chrome instead of smaller rows (ROW_H stays 44):
- titled screens drop the status bar; the title bar (APPBAR_H) holds back,
  the title, the screen's actions and the time/battery on a pill; the status
  bar returns for Home, the map, typing, the quick panel and the lock
- one radius (RADIUS) for everything tapped: buttons, tabs, choices, chips
- tabs are all segmented at TAB_H in the body (GPS, Clock converted)
- Nodes: chips row + sort + status line become one toolbar row with
  Show / Order choices; Messages' All/Favourites is the same choice
- nav map: the target bar only while there is a target; the pin opens one
  "Places" popup; back from a map options page restores the map and Map
  tools as left, without a jump
- node detail actions at the top of the scroll; Home tiles fill the page
- rowsFit() leaves growing labels to the flex (a screen title cut to one
  letter after Messages' filter changed)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-14 12:51:03 +02:00
co-authored by Claude Opus 5.5
parent a8440c890e
commit c779bf46b5
23 changed files with 408 additions and 244 deletions
+51 -7
View File
@@ -164,6 +164,20 @@ static lv_obj_t* s_status_bar = nullptr; // on the top layer, opaque
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
@@ -245,7 +259,7 @@ namespace freeze {
}
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) { // the status bar, on the top layer too, stays dimmed
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);
@@ -306,6 +320,9 @@ namespace freeze {
// 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()
@@ -335,12 +352,15 @@ static lv_obj_t* popupOpen(lv_obj_t* parent, PopFit fit, lv_obj_t*& overlay, int
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 (freeze::early(overlay, LV_OPA_60)) anim::rise(panel);
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;
@@ -470,7 +490,7 @@ static lv_obj_t* pillButton(lv_obj_t* row, const char* text, uint32_t bg, lv_eve
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, LV_RADIUS_CIRCLE, 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);
@@ -617,14 +637,15 @@ static void rowFit(lv_obj_t* row) {
}
}
// ... and every label given a width in pixels, which labelFit() then measures
// ... 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)) {
} 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);
}
@@ -827,7 +848,7 @@ static lv_obj_t* choiceCreate(lv_obj_t* parent, const char* opts, int sel, const
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_SM, 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);
}
@@ -853,6 +874,29 @@ static lv_obj_t* choiceCreate(lv_obj_t* parent, const char* opts, int sel, const
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,
@@ -931,7 +975,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::BG), 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_SM, LV_PART_ITEMS);
lv_obj_set_style_radius(m, theme::RADIUS, LV_PART_ITEMS);
return m;
}