Let every screen choose its top bar: clocks, date and entities (0.2.38)

The name stays on the left; up to six items go on the right: the time, an
analog clock, the date, or a Home Assistant entity with its icon, showing its
state or how long ago it changed, always or only while active. The add-on
formats entity text in Dutch and sends it as op: header right after the layout
to firmware 0.2.32+; older firmware keeps the clock of show_clock, which now
follows the clock item.

The firmware draws every value in one font on the name's baseline, centres
icons and the dial on the digit height and measures gaps between glyph ink, so
the bar looks even; the editor's mockup uses Roboto and the same integer rules.
tools/render_topbar.py renders the real LVGL code on a computer (ESPHome host +
SDL2). The editor's section buttons are gone.

Firmware 0.2.32 for both boards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-14 18:38:49 +02:00
parent ae308e37ab
commit 3f6889f750
25 changed files with 2365 additions and 94 deletions
+78
View File
@@ -0,0 +1,78 @@
#include "components/smart_display/header_bar.h"
#include <cassert>
#include <cstdio>
using namespace header_bar;
int main() {
assert(kind("clock") == Kind::clock && kind("analog") == Kind::analog && kind("date") == Kind::date);
assert(kind("text") == Kind::text && kind("ago") == Kind::ago);
for (const char *bad : {"", "Clock", "tijd", "state"}) assert(kind(bad) == Kind::none);
uint32_t c = 0;
assert(color("FFB300", c) && c == 0xFFB300);
assert(color("43a047", c) && c == 0x43A047);
for (const char *bad : {"", "FFB30", "FFB3000", "#FFB30", "GGGGGG"}) assert(!color(bad, c));
// UTF-8: ASCII, two, three and four bytes; a broken sequence still moves on.
std::string s = "a°—\U000F050F";
size_t i = 0;
assert(next_codepoint(s, i) == 'a' && next_codepoint(s, i) == 0xB0 && next_codepoint(s, i) == 0x2014);
assert(next_codepoint(s, i) == 0xF050F && next_codepoint(s, i) == 0 && i == s.size());
std::string broken = "\xE2" "x";
i = 0;
next_codepoint(broken, i);
assert(next_codepoint(broken, i) == 'x');
// The editor's agoText() uses the same words and thresholds.
const int64_t now = 1789401840;
assert(ago_text(now, 0) == "—" && ago_text(0, now) == "—");
assert(ago_text(now - 59, now) == "Zojuist");
assert(ago_text(now - 60, now) == "1 min geleden");
assert(ago_text(now - 3599, now) == "59 min geleden");
assert(ago_text(now - 3600, now) == "1 uur geleden");
assert(ago_text(now - 86399, now) == "23 uur geleden");
assert(ago_text(now - 86400, now) == "Gisteren");
assert(ago_text(now - 172800, now) == "2 dagen geleden");
assert(ago_text(now - 604800, now) == "1 week geleden");
assert(ago_text(now - 2 * 604800, now) == "2 weken geleden");
assert(ago_text(now - 2592000, now) == "1 maand geleden");
assert(ago_text(now - 3 * 2592000, now) == "3 maanden geleden");
assert(ago_text(now - 31536000, now) == "1 jaar geleden");
assert(ago_text(now + 30, now) == "Over 1 min");
assert(ago_text(now + 600, now) == "Over 10 min");
assert(ago_text(now + 7200, now) == "Over 2 uur");
assert(ago_text(now + 90000, now) == "Morgen");
assert(ago_text(now + 3 * 86400, now) == "Over 3 dagen");
assert(date_text(2, 14, 9) == "ma 14 sep" && date_text(1, 1, 1) == "zo 1 jan" && date_text(7, 31, 12) == "za 31 dec");
assert(date_text(0, 1, 1) == "—" && date_text(1, 1, 13) == "—");
// Guition: digits 15 px high; CYD: 10 px.
auto g = gaps(15);
assert(g.icon == 6 && g.item == 19 && g.name == 24);
auto small = gaps(10);
assert(small.icon == 4 && small.item == 13 && small.name == 16);
assert(gaps(1).icon == 2 && gaps(1).item == 6 && gaps(1).name == 8);
// Everything fits: right-aligned with item gaps, the name keeps the rest.
int widths[] = {80, 50, 49};
auto p = place(widths, 3, g, 448, 100);
assert(p.first == 0 && p.x[2] == 448 - 49 && p.x[1] == p.x[2] - 19 - 50 && p.x[0] == p.x[1] - 19 - 80);
assert(p.name_room == p.x[0] - 24);
// Too full: items leave from the front until the rest fits beside the name.
int many[] = {86, 163, 50, 60};
auto q = place(many, 4, g, 448, 100);
assert(q.first == 1);
int total = 163 + 50 + 60 + 2 * 19;
assert(q.x[1] == 448 - total && q.name_room >= 100);
// A long name keeps 35% of the width, never more than it needs.
auto r = place(many, 4, g, 448, 400);
assert(r.first == 2 && r.name_room >= 448 * 35 / 100);
// Nothing to show: the name gets the whole width.
auto empty = place(many, 0, g, 448, 400);
assert(empty.first == 0 && empty.name_room == 448);
// Items wider than the bar all leave.
int huge[] = {500};
auto none = place(huge, 1, g, 448, 50);
assert(none.first == 1 && none.name_room == 448);
std::puts("test_header_bar: PASS");
}