Add a PC renderer for the themed pages; fix overlapping layouts

tools/render_pages.py compiles the page code natively with g++ against
ESPHome's own display and font code and the font bitmaps of the last
`esphome compile`, and draws pages to PNG at chosen moments with sample
data (normal, empty, alert, busy), as a contact sheet, or as one example
per page in docs/images/pages (now shown in the README).

Fixes found with it: Cyber HUD zone names ran into the temperatures,
the breach header label hit the tag and the column marker the matrix
label; KonoSuba subtitle, guild weather icon, cabbage text and all-day
deadline, and hills through the party; Steins;Gate D-mail recipient, the
microwave display, the gadget card value and world line labels; LCARS
room rows, readout bars and data cascade, warp core readouts, and the
captain's log weather wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-19 16:46:31 +02:00
1 parent 13a499fd97
commit d2e81a5a36
27 files changed
+627 -38

No files matched your search

+7 -6
View File
@@ -404,9 +404,10 @@ void netrunner_hud(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime
cut_frame(it, 254, 150, 258, 86);
tag(it, f.mono, 254, 150, 14, "ZONES");
for (int i = 0; i < 4; i++) {
const int x = 264 + (i % 2) * 124, y = 192 + (i / 2) * 34;
it.print(x, y, f.label, TextAlign::BASELINE_LEFT, el512::fit(f.label, d.rooms[i].name, 62).c_str());
it.print(x + 116, y, f.value, TextAlign::BASELINE_RIGHT, temp_text(d.rooms[i].value).c_str());
// Name on top, temperature below it.
const int x = 264 + (i % 2) * 124, y = 164 + (i / 2) * 36;
it.print(x, y + 11, f.mono, TextAlign::BASELINE_LEFT, el512::fit(f.mono, upper(d.rooms[i].name), 114).c_str());
it.print(x, y + 33, f.value, TextAlign::BASELINE_LEFT, temp_text(d.rooms[i].value).c_str());
}
// Bottom: ticker.
@@ -432,7 +433,7 @@ void breach_protocol(EL512Display &it, const Fonts &f) {
// Header and timer.
tag(it, f.title, 0, 0, 24, "BREACH PROTOCOL");
it.print(420, 18, f.label, TextAlign::BASELINE_RIGHT, "BREACH TIME REMAINING");
it.print(420, 18, f.label, TextAlign::BASELINE_RIGHT, "TIME REMAINING");
cut_frame(it, 428, 0, 84, 26, 6);
const bool low = v.remain < 10.0f && (t / 250) % 2;
if (!low)
@@ -441,8 +442,8 @@ void breach_protocol(EL512Display &it, const Fonts &f) {
// Code matrix.
cut_frame(it, 0, 40, 296, 216);
it.print(10, 54, f.mono, TextAlign::BASELINE_LEFT, "CODE MATRIX");
draw_matrix(it, {10, 62, 46, 31, f.code}, p, v);
it.print(10, 52, f.mono, TextAlign::BASELINE_LEFT, "CODE MATRIX");
draw_matrix(it, {10, 64, 46, 31, f.code}, p, v);
// Buffer.
it.print(304, 54, f.mono, TextAlign::BASELINE_LEFT, "BUFFER");
+6
View File
@@ -96,6 +96,12 @@ inline std::string upper(std::string s) {
return s;
}
inline std::string lower(std::string s) {
for (auto &c : s)
c = (char) tolower((unsigned char) c);
return s;
}
inline int text_width(display::Display &it, display::BaseFont *font, const char *text) {
int x, y, w, h;
it.get_text_bounds(0, 0, text, font, display::TextAlign::TOP_LEFT, &x, &y, &w, &h);
+7 -7
View File
@@ -215,7 +215,7 @@ void explosion(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now)
// Title and clock (hidden while the sky is on fire).
if (!blasting) {
it.print(8, 4, f.jp_big, TextAlign::TOP_LEFT, "爆裂魔法");
it.print(10, 52, f.pixel, TextAlign::TOP_LEFT, "EXPLOSION MAGIC");
it.print(10, 56, f.pixel, TextAlign::TOP_LEFT, "EXPLOSION MAGIC");
if (now.is_valid())
it.strftime(504, 6, f.pixel_l, TextAlign::TOP_RIGHT, "%H:%M", now);
}
@@ -372,7 +372,7 @@ void guild_board(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime no
if (quests.size() == 3)
break;
const std::string day = field(e, 0), time = field(e, 1);
quests.push_back({field(e, 2), upper(day) + " " + (time.empty() ? "ALL DAY" : time), true, day == today});
quests.push_back({field(e, 2), upper(day) + (time.empty() ? "" : " " + time), true, day == today});
}
static const char *const STANDING[][2] = {{"Exterminate five giant toads", "BEFORE DUSK"},
{"Escort a merchant cart to the next town", "THIS WEEK"},
@@ -412,13 +412,13 @@ void guild_board(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime no
// Hazard notice (weather) and the cabbage bounty (solar).
note(it, 386, 48, 116, 94);
it.print(444, 60, f.pixel, TextAlign::TOP_CENTER, "HAZARD");
it.print(416, 102, f.weather, TextAlign::CENTER, weather_icon(d.weather));
it.print(416, 98, f.weather, TextAlign::CENTER, weather_icon(d.weather));
it.print(492, 94, f.text, TextAlign::BASELINE_RIGHT, std::isnan(d.temp) ? "--" : str("%.0f°", d.temp).c_str());
it.print(444, 128, f.pixel, TextAlign::TOP_CENTER, fit(f.pixel, upper(weather_text(d.weather)), 100).c_str());
it.print(444, 131, f.pixel, TextAlign::TOP_CENTER, fit(f.pixel, upper(weather_text(d.weather)), 100).c_str());
note(it, 386, 150, 116, 94);
it.print(444, 162, f.pixel, TextAlign::TOP_CENTER, "BOUNTY");
it.print(444, 176, f.text, TextAlign::TOP_CENTER, "Cabbage harvest");
it.print(444, 176, f.text, TextAlign::TOP_CENTER, "Cabbage crop");
it.print(444, 198, f.text, TextAlign::TOP_CENTER,
std::isnan(d.solar_today) ? "--" : str("%.1f kWh", d.solar_today / 1000.0f).c_str());
it.printf(444, 222, f.pixel, TextAlign::TOP_CENTER, "NOW %s", upper(watts_text(d.solar)).c_str());
@@ -535,8 +535,8 @@ void party_battle(EL512Display &it, const Fonts &f, const HomeData &d) {
// Scenery.
it.print(8, 6, f.jp, TextAlign::TOP_LEFT, "戦闘");
it.printf(56, 12, f.pixel, TextAlign::TOP_LEFT, "ROUND %u", (unsigned) (round + 1));
arc(it, 110, GROUND + 70, 110, -52, 52);
arc(it, 300, GROUND + 90, 120, -40, 40);
// A hill between the party and the toad, clear of both.
arc(it, 270, GROUND + 50, 80, -46, 46);
it.horizontal_line(0, GROUND, W);
for (int i = 0; i < 18; i++) {
const int x = (int) (hash(i, 21) % W);
+24 -17
View File
@@ -223,7 +223,7 @@ void d_mail(EL512Display &it, const Fonts &f, const HomeData &d) {
for (int b = 0; b < 4; b++)
it.filled_rectangle(146 + b * 6, 26 - b * 2 - 2, 4, b * 2 + 2, COLOR_OFF);
if (c < SEND) {
it.print(35, 34, f.pixel, TextAlign::TOP_LEFT, "TO: ME, 1 WEEK AGO");
it.print(35, 34, f.pixel, TextAlign::TOP_LEFT, "TO: ME, -1 WEEK");
it.horizontal_line(34, 45, 138);
std::string body = c < TYPE_AT ? "" : typed(msg, c - TYPE_AT, CPS);
if ((t / 300) % 2)
@@ -267,7 +267,11 @@ void d_mail(EL512Display &it, const Fonts &f, const HomeData &d) {
it.draw_pixel_at(x, y, COLOR_ON);
it.printf(MX + 209, MY + 23, f.mono, TextAlign::CENTER, "0:%02u", (unsigned) ((ZAP + 300 - c) / 1000));
} else {
it.print(MX + 209, MY + 23, f.mono, TextAlign::CENTER, upper(watts_text(d.house)).c_str());
// The microwave's little display: "850W" or "12.0kW".
const std::string lcd = std::isnan(d.house) ? "--"
: d.house >= 1000 ? str("%.1fkW", d.house / 1000.0f)
: str("%.0fW", d.house);
it.print(MX + 209, MY + 23, f.mono, TextAlign::CENTER, lcd.c_str());
}
// Turntable with a banana on it.
for (int a = 0; a < 360; a += 15) {
@@ -374,17 +378,19 @@ void gadget_lab(EL512Display &it, const Fonts &f, const HomeData &d) {
// Detail card for the selected gadget.
const Gadget &g = gadgets[sel];
const uint32_t since = t % STEP;
it.rectangle(340, 38, 170, 128);
it.rectangle(342, 40, 166, 124);
it.printf(350, 46, f.title, TextAlign::TOP_LEFT, "NO.%s", g.no.c_str());
it.print(502, 46, f.title, TextAlign::TOP_RIGHT, g.value.c_str());
int y = 74;
// Number, name, value, then the description.
it.rectangle(340, 38, 170, 140);
it.rectangle(342, 40, 166, 136);
it.printf(350, 44, f.title, TextAlign::TOP_LEFT, "NO.%s", g.no.c_str());
int y = 68;
for (const auto &l : wrap(it, f.mono, g.name, 150, 2)) {
it.print(350, y, f.mono, TextAlign::TOP_LEFT, l.c_str());
y += 15;
}
it.horizontal_line(350, y + 2, 150);
y += 8;
it.print(350, y + 2, f.title, TextAlign::TOP_LEFT, g.value.c_str());
y += 26;
it.horizontal_line(350, y, 150);
y += 5;
for (const auto &l : wrap(it, f.mono, typed(g.desc, since, 40), 150, 3)) {
it.print(350, y, f.mono, TextAlign::TOP_LEFT, l.c_str());
y += 15;
@@ -429,7 +435,7 @@ void world_lines(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime no
}
for (int h = 0; h <= 24; h += 6) {
it.vertical_line(xmap(h), PY1, 4);
it.printf(xmap(h), PY1 + 8, f.pixel, TextAlign::TOP_CENTER, "%02d:00", h % 24);
it.printf(xmap(h), PY1 + 8, f.pixel, h == 24 ? TextAlign::TOP_RIGHT : TextAlign::TOP_CENTER, "%02d:00", h);
}
// Attractor fields.
@@ -441,14 +447,15 @@ void world_lines(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime no
const int phase = (int) (t / 80);
for (const auto &fl : fields) {
const int y = ymap(fl.div);
if (fl.div == 1.048596f) {
// Labels on the left, where the chart stays empty until sunrise: Beta's
// above its line, the others below theirs (Steins Gate and Beta are only a
// few pixels apart).
if (fl.div == 1.048596f)
it.horizontal_line(PX0 + 1, y, PX1 - PX0);
it.print(PX1, y - 3, f.pixel, TextAlign::BOTTOM_RIGHT, fl.name);
} else {
else
dashed_hline(it, PX0 + 1, y, PX1 - PX0, 4, 4, phase);
it.print(PX1, fl.div > 1.1f ? y - 3 : y + 3, f.pixel, fl.div > 1.1f ? TextAlign::BOTTOM_RIGHT : TextAlign::TOP_RIGHT,
fl.name);
}
const bool above = fl.div > 1.1f;
it.print(PX0 + 6, above ? y - 3 : y + 3, f.pixel, above ? TextAlign::BOTTOM_LEFT : TextAlign::TOP_LEFT, fl.name);
}
// Today's world line, drawn in over the first 1.5 s.
@@ -472,7 +479,7 @@ void world_lines(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime no
points++;
}
if (points == 0 && (t / 600) % 2)
it.print((PX0 + PX1) / 2, (PY0 + PY1) / 2 + 30, f.mono, TextAlign::CENTER, "COLLECTING WORLD LINE DATA...");
it.print((PX0 + PX1) / 2 - 40, PY1 - 10, f.mono, TextAlign::CENTER, "COLLECTING WORLD LINE DATA...");
// Where we are now, and the futures branching off it.
if (now.is_valid() && t >= 1500) {
+7 -7
View File
@@ -173,16 +173,16 @@ void lcars_status(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime n
for (int i = 0; i < 3; i++) {
const int y = 48 + i * 24;
block(it, f.small, 84, y, 66, 18, rows[i].name);
readout(it, 156, y + 4, 90, 10, rows[i].w / 5000.0f);
readout(it, 156, y + 4, 80, 10, rows[i].w / 5000.0f);
it.print(298, y + 18, f.small, TextAlign::BOTTOM_RIGHT, kw(rows[i].w).c_str());
}
// Environmental.
it.print(84, 128, f.small, TextAlign::TOP_LEFT, "ENVIRONMENTAL DECKS 1-4");
for (int i = 0; i < 4; i++) {
const int x = 84 + (i % 2) * 110, y = 150 + (i / 2) * 40;
const int x = 84 + (i % 2) * 110, y = 148 + (i / 2) * 44;
it.print(x, y, f.small, TextAlign::TOP_LEFT, fit(f.small, upper(d.rooms[i].name), 104).c_str());
it.print(x, y + 16, f.medium, TextAlign::TOP_LEFT, deg(d.rooms[i].value).c_str());
it.print(x, y + 14, f.medium, TextAlign::TOP_LEFT, deg(d.rooms[i].value).c_str());
}
// Clock, sensors, fabrication bay.
@@ -200,7 +200,7 @@ void lcars_status(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime n
for (int col = 0; col < 2; col++) {
for (int row = 0; row < 9; row++) {
const uint32_t r = hash(col * 31 + row, (t / 180) + row * 3);
const int x = 446 + col * 32, y = 96 + row * 12;
const int x = 456 + col * 30, y = 96 + row * 12;
if (r % 5 == 0)
continue;
it.printf(x, y, f.pixel, TextAlign::TOP_LEFT, "%03u", (unsigned) (r % 1000));
@@ -325,8 +325,8 @@ void warp_core(EL512Display &it, const Fonts &f, const HomeData &d) {
for (int i = 0; i < 3; i++) {
const int y = 36 + i * 44;
it.print(0, y, f.small, TextAlign::TOP_LEFT, rows[i].name);
it.print(204, y, f.small, TextAlign::TOP_RIGHT, rows[i].value.c_str());
readout(it, 0, y + 22, 200, 10, rows[i].frac);
it.print(196, y, f.small, TextAlign::TOP_RIGHT, rows[i].value.c_str());
readout(it, 0, y + 22, 196, 10, rows[i].frac);
}
it.print(0, 172, f.small, TextAlign::TOP_LEFT, "MATTER / ANTIMATTER 1:1");
it.print(0, 192, f.small, TextAlign::TOP_LEFT, "DILITHIUM MATRIX STABLE");
@@ -409,7 +409,7 @@ void captains_log(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime n
std::string entry = "We are holding position at home.";
if (!std::isnan(d.temp))
entry += str(" Outside it is %.0f degrees", d.temp) +
(d.weather.empty() ? std::string(".") : " and " + weather_text(d.weather) + ".");
(d.weather.empty() ? std::string(".") : " and " + lower(weather_text(d.weather)) + ".");
if (!std::isnan(d.solar))
entry += " The solar collectors deliver " + watts(d.solar) +
(std::isnan(d.house) ? std::string(".") : " while the replicators draw " + watts(d.house) + ".");