diff --git a/AGENTS.md b/AGENTS.md index 6a0560f..365fecf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 468af4f..8231b2e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/sdcard/lib/statusbar.lua b/sdcard/lib/statusbar.lua index 3d0ed9c..f8dfc3d 100644 --- a/sdcard/lib/statusbar.lua +++ b/sdcard/lib/statusbar.lua @@ -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() diff --git a/src/lua/bindings/gui.cpp b/src/lua/bindings/gui.cpp index c0f3655..bf84fad 100644 --- a/src/lua/bindings/gui.cpp +++ b/src/lua/bindings/gui.cpp @@ -3,6 +3,8 @@ #include #include +#include + #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; } diff --git a/src/lua/lua_app.cpp b/src/lua/lua_app.cpp index 11fd16e..3590b13 100644 --- a/src/lua/lua_app.cpp +++ b/src/lua/lua_app.cpp @@ -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) { diff --git a/src/lua/lua_app.h b/src/lua/lua_app.h index c689a81..cf45689 100644 --- a/src/lua/lua_app.h +++ b/src/lua/lua_app.h @@ -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.