fix(gui): survive font rendering under low heap

Cache font heights, release FreeType glyph state between apps, and catch renderer allocation failures before they cross Lua and abort the device. Individual low-memory draws degrade to allocation-free Font 1. Show disconnected WiFi as three faded zero-signal bars without expanding the icon footprint.
This commit is contained in:
2026-08-02 15:14:08 -04:00
parent a8602bbd56
commit 9a88d03d23
6 changed files with 80 additions and 36 deletions
+4 -2
View File
@@ -50,8 +50,10 @@ within an app.
Lua text always uses one OpenFontRender TTF face: embedded Meslo by default, or one SD
font selected by `gui.setFont(path)`. Sizes are per draw/measurement call and UI nodes use
`fontSize`; never load a face per node. A new app restores the embedded face. Font 1 is
reserved for C++ failure screens.
`fontSize`; never load a face per node. A new app restores the embedded face and drops the
previous glyph cache. OpenFontRender allocates C++ queues even for measurement: cache font
heights, and keep the `std::bad_alloc` boundary in `gui.cpp` so low heap falls back to Font
1 instead of aborting across Lua. Font 1 also serves C++ failure screens.
## Status Bar
+3 -2
View File
@@ -87,8 +87,9 @@ local height = gui.getFontHeight(20)
`gui.setFont("/fonts/MyFont.ttf")` replaces the selected face with a TTF from SD;
`gui.setFont(nil)` restores Meslo. One face is retained at a time, SD fonts are capped at
4MB, and every app starts on Meslo. UI text accepts `fontSize = 20` and defaults to 14.
The embedded font contains printable ASCII and Latin-1; Font 1 remains compiled in only
for C++ failure screens that must work when the renderer does not.
The embedded font contains printable ASCII and Latin-1. Font 1 remains compiled as the
allocation-free fallback for C++ failure screens and for individual draws when FreeType
cannot allocate under severe heap pressure.
## Settings
+26 -21
View File
@@ -13,7 +13,7 @@ local PAD = 6
-- often it calls draw().
local M = {height = 44, interval = 1000}
local BAR_H = M.height
local WIFI_W = 17
local WIFI_W = 11
local GAP = 8
local TITLE_FONT_SIZE = 14
local INFO_FONT_SIZE = 10
@@ -21,6 +21,7 @@ local INFO_FONT_SIZE = 10
-- What is currently on the panel. Every field repaints only when its value changes, so
-- the once-a-second tick costs one clock rectangle instead of a whole bar.
local shown = {}
local geometry = {}
local function clock()
return sys.isClockSynced() and os.date("%H:%M:%S") or "--:--:--"
@@ -43,10 +44,7 @@ local function drawWifi(x, y, bars, color, muted)
local h = i * 3
gui.fillRect(x + (i - 1) * 4, y + 9 - h, 3, h, i <= bars and color or muted)
end
if bars == 0 then
gui.drawLine(x + 12, y + 3, x + 16, y + 7, color)
gui.drawLine(x + 16, y + 3, x + 12, y + 7, color)
end
end
-- Fills the leading square of the bar, which is the rect the firmware treats as back.
@@ -73,25 +71,33 @@ end
function M.draw(hasBack)
local theme = ui.theme
local w = gui.getWidth()
local titleH = gui.getFontHeight(TITLE_FONT_SIZE)
local infoH = gui.getFontHeight(INFO_FONT_SIZE)
local titleY = (BAR_H - 1 - titleH) // 2
local topY, bottomY = PAD, BAR_H - 1 - PAD - infoH
-- Fixed slot widths keep the title and neighbouring fields still as values change.
local clockW = gui.getTextWidth("00:00:00", INFO_FONT_SIZE)
local memW = gui.getTextWidth("000kB", INFO_FONT_SIZE)
local clockX = w - PAD - clockW
local signalX = w - PAD - WIFI_W
local memX = signalX - GAP - memW
local rightX = math.min(clockX, memX)
local rotation, font = gui.getRotation(), gui.getFont()
local frameKey = rotation .. "|" .. font
if geometry.key ~= frameKey then
local w = gui.getWidth()
local titleH = gui.getFontHeight(TITLE_FONT_SIZE)
local infoH = gui.getFontHeight(INFO_FONT_SIZE)
local clockW = gui.getTextWidth("00:00:00", INFO_FONT_SIZE)
local memW = gui.getTextWidth("000kB", INFO_FONT_SIZE)
local clockX = w - PAD - clockW
local signalX = w - PAD - WIFI_W
local memX = signalX - GAP - memW
geometry = {
key = frameKey, w = w, titleY = (BAR_H - 1 - titleH) // 2,
topY = PAD, bottomY = BAR_H - 1 - PAD - infoH, infoH = infoH,
clockW = clockW, memW = memW, clockX = clockX, signalX = signalX,
memX = memX, rightX = math.min(clockX, memX),
}
end
local w, titleY, topY, bottomY = geometry.w, geometry.titleY, geometry.topY, geometry.bottomY
local infoH, clockW, memW = geometry.infoH, geometry.clockW, geometry.memW
local clockX, signalX, memX, rightX = geometry.clockX, geometry.signalX, geometry.memX, geometry.rightX
-- Rotation moves every slot, the theme recolors them, and an app may rename itself at
-- any time. A new app gets a fresh Lua state, so an empty cache already means "repaint
-- everything".
local name = sys.getAppName()
local key = gui.getRotation() .. "|" .. ui.themeName .. "|" .. gui.getFont() .. "|" .. name
local key = frameKey .. "|" .. ui.themeName .. "|" .. name
if key ~= shown.key then
shown = {key = key}
gui.fillRect(0, 0, w, BAR_H, theme.bg)
@@ -112,8 +118,7 @@ function M.draw(hasBack)
if mem ~= shown.mem then
shown.mem = mem
gui.fillRect(memX, bottomY, memW, infoH, theme.bg)
gui.drawText(mem, memX + memW - gui.getTextWidth(mem, INFO_FONT_SIZE), bottomY,
theme.muted, theme.bg, INFO_FONT_SIZE)
gui.drawText(mem, memX, bottomY, theme.muted, theme.bg, INFO_FONT_SIZE)
end
local bars = signalBars()
+40 -9
View File
@@ -3,6 +3,8 @@
#include <SD.h>
#include <TFT_eSPI.h>
#include <new>
#include "../../gfx/round_rect.h"
#include "../bindings.h"
#include "../lua_app.h"
@@ -104,20 +106,35 @@ static unsigned fontSize(lua_State* L, int index) {
}
static int l_gui_fontHeight(lua_State* L) {
lua_pushinteger(L, app(L)->fontHeight(fontSize(L, 1)));
return 1;
try {
lua_pushinteger(L, app(L)->fontHeight(fontSize(L, 1)));
return 1;
} catch (const std::bad_alloc&) {
lua_pushinteger(L, fontSize(L, 1));
return 1;
}
}
static int l_gui_textWidth(lua_State* L) {
const char* text = luaL_checkstring(L, 1);
lua_pushinteger(L, app(L)->textWidth(text, fontSize(L, 2)));
return 1;
unsigned size = fontSize(L, 2);
try {
lua_pushinteger(L, app(L)->textWidth(text, size));
return 1;
} catch (const std::bad_alloc&) {
lua_pushinteger(L, strlen(text) * size * 4 / 7);
return 1;
}
}
static int l_gui_setFont(lua_State* L) {
if (lua_isnoneornil(L, 1)) {
if (!app(L)->setFont(nullptr)) return luaL_error(L, "default font unavailable");
return 0;
try {
if (!app(L)->setFont(nullptr)) return luaL_error(L, "default font unavailable");
return 0;
} catch (const std::bad_alloc&) {
return luaL_error(L, "out of memory loading font");
}
}
const char* path = luaL_checkstring(L, 1);
@@ -126,8 +143,12 @@ static int l_gui_setFont(lua_State* L) {
size_t size = file.size();
file.close();
if (!size || size > MAX_FONT_FILE) return luaL_error(L, "font must be 1 byte to 4MB");
if (!app(L)->setFont(path)) return luaL_error(L, "invalid font: %s", path);
return 0;
try {
if (!app(L)->setFont(path)) return luaL_error(L, "invalid font: %s", path);
return 0;
} catch (const std::bad_alloc&) {
return luaL_error(L, "out of memory loading font");
}
}
static int l_gui_getFont(lua_State* L) {
@@ -141,7 +162,17 @@ static int l_gui_drawText(lua_State* L) {
int y = luaL_checkinteger(L, 3);
uint16_t fg = luaL_optinteger(L, 4, TFT_BLACK);
uint16_t bg = luaL_optinteger(L, 5, TFT_WHITE);
app(L)->drawText(text, x, y, fg, bg, fontSize(L, 6));
try {
app(L)->drawText(text, x, y, fg, bg, fontSize(L, 6));
} catch (const std::bad_alloc&) {
// Text remains legible when FreeType cannot allocate its short-lived glyph queues.
// Font 1 is already in flash and allocates nothing, so low memory degrades instead of
// crossing the Lua C boundary as an uncaught C++ exception and aborting the device.
app(L)->tft.setTextFont(1);
app(L)->tft.setTextSize(1);
app(L)->tft.setTextColor(fg, bg);
app(L)->tft.drawString(text, x, y);
}
return 0;
}
+6 -2
View File
@@ -84,11 +84,14 @@ void LuaApp::resetFontRenderer() {
font->setDrawer(tft);
fontLoaded = false;
currentFont = "";
std::fill(fontHeightCache, fontHeightCache + 97, 0);
}
bool LuaApp::setFont(const char* path) {
String requested = path ? path : "builtin:MesloLGS-Regular";
if (fontLoaded && requested == currentFont) return true;
// Reload the embedded face for every app so FreeType's glyph cache belongs to the app
// that populated it and is released at the same lifecycle boundary as the Lua state.
if (path && fontLoaded && requested == currentFont) return true;
resetFontRenderer();
FT_Error error = path ? font->loadFont(path) : font->loadFont(mesloStart, mesloEnd - mesloStart);
@@ -117,8 +120,9 @@ int LuaApp::textWidth(const char* text, unsigned size) {
}
int LuaApp::fontHeight(unsigned size) {
if (fontHeightCache[size]) return fontHeightCache[size];
font->setFontSize(size);
return font->getTextHeight("Mg");
return fontHeightCache[size] = font->getTextHeight("Mg");
}
void LuaApp::drawText(const char* text, int x, int y, uint16_t fg, uint16_t bg, unsigned size) {
+1
View File
@@ -101,6 +101,7 @@ class LuaApp {
OpenFontRender* font = nullptr;
String currentFont;
bool fontLoaded = false;
int fontHeightCache[97] = {};
// One strike: a bar that failed once fails identically every second, and the serial
// log is the only place anyone would see it. The strip stays reserved either way, so a
// bar that dies mid-run never resizes the app underneath it.