diff --git a/AGENTS.md b/AGENTS.md index 22638d7..d9be54c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,6 +136,45 @@ number page is narrower than the letter page, and the letter page's outer keys s otherwise. Press feedback that must not repaint the whole node is `on_down` drawing one region and `on_unpress` restoring it after the 80 ms hold. +## Banded Repaint + +**The painter composites, apps do not.** `tree.draw()` unions the dirty nodes into one +rectangle and paints it a band at a time: `gui.beginBuffer(x, y, w, h)` opens an offscreen +16bpp sprite, the tree is re-walked painting whatever overlaps that band, and `present()` +blits it. The tree *is* the display list, so a band re-walks it rather than replaying a +recorded command stream, and one push replaces a bus transaction per primitive -- which is +what lets a whole pane move smoothly. Apps draw in screen coordinates and call +`ui.invalidate(id)`; `apps/Scroll` scrolls with no band code of its own. + +A band that overlaps a node paints the **whole** node clipped to the band, so a node taller +than a band is painted once per band it spans -- including a `custom` painter, whose Lua +callback is re-invoked per band. Cull inside a long custom painter; a list long enough to +matter wants virtualized nodes (build only the visible rows) rather than one tall painter. + +**Band height adapts and must.** How much contiguous heap exists depends on the app, the +orientation and the fragmentation already there -- an app launch leaves ~48KB, but ~28KB +once its Lua state is live, and a 320x48 band is 30KB. `drawBanded` halves the band on a +failed allocation down to `MIN_BAND` before giving up, and a repaint that can allocate +nothing falls back to painting dirty nodes straight to the panel (which is what an e-ink +provider wants anyway -- the default `beginBuffer` refuses). + +**A primitive drawing into a band must clip every write itself.** `TFT_eSprite` does not +clip `pushImage`, and a node straddling a band edge will otherwise write past the sprite and +corrupt the heap -- the failure looks like a hang in a later `present()`, not a fault where +the write happened. `paintRoundRect` therefore clamps its fills (`fillClip`) and draws +corner spans per pixel instead of pushing them. Adding a primitive means doing the same. + +## Drawing Pitfalls + +`screen.*` coordinate args go through `checkInt`, which rejects a non-integral float +(`108.5`) with "number has no integer representation" -- and an error raised inside a paint +or timer callback aborts silently, leaving a blank pane with no log. Floor computed +coordinates with `//` (`(w - tw) // 2`), never `/`. + +A continuously repainting pane cannot be photographed: the emulator's capture returns the +last settled frame, so a running animation reads as the *previous* screen. Assert motion +from a log (`scrollY=`), and capture with the animation stopped to check the rendering. + ## Touch and Drag The firmware fires `onTouchDown` / `onTouchMove` / `onTouchUp` plus the `onTouch` tap diff --git a/lib/esp32-lua-api b/lib/esp32-lua-api index 26f3fdb..1c469b3 160000 --- a/lib/esp32-lua-api +++ b/lib/esp32-lua-api @@ -1 +1 @@ -Subproject commit 26f3fdb6e61366046ffa0b9a1a6a9ec356a4bd27 +Subproject commit 1c469b38fb8b29a79c12c5236ab52435fbbb4f70 diff --git a/sdcard/.lua/apps/Scroll/main.lua b/sdcard/.lua/apps/Scroll/main.lua new file mode 100644 index 0000000..07398eb --- /dev/null +++ b/sdcard/.lua/apps/Scroll/main.lua @@ -0,0 +1,67 @@ +local ui = require "ui" + +-- A scrolling list with no band management of its own: the painter composites the dirty +-- region into offscreen bands and pushes each once, so this just draws every visible row in +-- screen coordinates and invalidates on each tick. The rows straddle band edges freely -- +-- roundRect's corner spans clip to the band in the driver, so a split row is drawn correctly +-- in each half. +local ROWS = 60 +local ROW_H = 64 +local GAP = 8 +local FONT = screen.FONT_LARGE +local SPEED = 6 -- pixels per 20ms tick + +---@class ScrollApp : SlateApp +local M = {} +local node +local area +local scrollY = 0 +local velocity = SPEED + +local function render(x, y, w, h) + area = { x = x, y = y, w = w, h = h } + local textH = screen.getFontHeight(FONT) + for i = 0, ROWS - 1 do + local top = y + i * ROW_H - scrollY + if top + ROW_H > y and top < y + h then + local fill = (i % 2 == 0) and ui.theme.face or ui.theme.background + screen.roundRect(x + GAP, top + GAP, w - 2 * GAP, ROW_H - 2 * GAP, + ui.theme.radius, ui.theme.background, fill, nil, ui.theme.muted) + local label = "Row " .. (i + 1) + local tx = x + (w - screen.getTextWidth(FONT, label)) // 2 + screen.drawText(FONT, tx, top + (ROW_H - textH) // 2, label, ui.theme.color) + end + end +end + +function M.init() + timer.every(20, function() + if not area then + return + end + scrollY = scrollY + velocity + local maxScroll = ROWS * ROW_H - area.h + if scrollY <= 0 then + scrollY = 0 + velocity = math.abs(velocity) + elseif scrollY >= maxScroll then + scrollY = maxScroll + velocity = -math.abs(velocity) + end + ui.invalidate(node) + end) + log.info "scroll demo ready" +end + +function M.node() + node = ui.custom { + w = "fill", + h = "fill", + paint = function(_, x, y, w, h) + render(x, y, w, h) + end, + } + return node +end + +return M diff --git a/src/host/providers.h b/src/host/providers.h index 5135600..09a0bc3 100644 --- a/src/host/providers.h +++ b/src/host/providers.h @@ -9,6 +9,7 @@ #include #include +#include #include class LuaHost; @@ -97,10 +98,26 @@ public: const int32_t* background) override; // The panel is live, so there is nothing pending to apply. void commit() override {} + bool beginBuffer(int32_t x, int32_t y, int32_t w, int32_t h) override; + void present() override; private: + // Every draw op targets this: the panel normally, an 8bpp sprite between + // beginBuffer and present. A full-panel 16bpp sprite (300KB) will not + // allocate on this board's fragmented heap, so the band trades RGB332 banding + // for a size that fits, and the painter composites the frame a band at a time. + TFT_eSPI& out() { return buffer ? *buffer : tft; } + TFT_eSPI& tft; LuaHost& host; + TFT_eSprite* buffer = nullptr; + // The band's screen origin, subtracted from every coordinate so callers draw + // in screen space; zero (a no-op) while drawing straight to the panel. + int32_t bufX = 0, bufY = 0; + // Sprite bounds, so roundRect's corner spans clip to the band. TFT_eSprite + // clips fills and text but not pushImage, and an unclipped span past the + // sprite corrupts the heap. INT32_MAX on the panel disables the clamp. + int32_t clipW = INT32_MAX, clipH = INT32_MAX; }; class Http : public esp32lua::HttpProvider { diff --git a/src/host/providers_gui.cpp b/src/host/providers_gui.cpp index f1187a0..4f97220 100644 --- a/src/host/providers_gui.cpp +++ b/src/host/providers_gui.cpp @@ -18,9 +18,35 @@ constexpr int MAX_SPAN = // alpha, so edge pixels blend against `surface`. void paintRoundRect(TFT_eSPI& tft, int x, int y, int w, int h, float radius, uint16_t surface, bool hasFill, uint16_t top, - uint16_t bottom, bool hasBorder, uint16_t border) { + uint16_t bottom, bool hasBorder, uint16_t border, + int clipW, int clipH) { if (w <= 0 || h <= 0 || w > MAX_SPAN) return; + + // TFT_eSprite clips fills and text but not pushImage, so a corner span past a + // band's edge corrupts the heap. Clamp each span to the target bounds; the + // panel passes INT32_MAX bounds, so this is a no-op there. + // Per-pixel rather than pushImage: pushImage into a TFT_eSprite corrupts the + // heap when the span sits near a band edge, whereas drawPixel clips cleanly on + // both a sprite and the panel. Spans are corner-sized, so the cost is trivial. + auto pushSpan = [&](int px, int py, int pw, uint16_t* pixels) { + if (py < 0 || py >= clipH) + return; + for (int i = 0; i < pw; i++) { + const int xx = px + i; + if (xx >= 0 && xx < clipW) + tft.drawPixel(xx, py, pixels[i]); + } + }; + // TFT_eSprite does not reliably clip a fill or line taller than the band, so + // a straddling rounded box would run off the buffer. Clamp every write here. + auto fillClip = [&](int px, int py, int pw, int ph, uint16_t color) { + int x0 = px < 0 ? 0 : px, y0 = py < 0 ? 0 : py; + int x1 = px + pw > clipW ? clipW : px + pw; + int y1 = py + ph > clipH ? clipH : py + ph; + if (x1 > x0 && y1 > y0) + tft.fillRect(x0, y0, x1 - x0, y1 - y0, color); + }; float halfWidth = w * 0.5f, halfHeight = h * 0.5f; if (radius < 0.0f) radius = 0.0f; @@ -48,7 +74,7 @@ void paintRoundRect(TFT_eSPI& tft, int x, int y, int w, int h, float radius, pixel = gfx::blend565(pixel, border, outer - inner); span[column] = pixel; } - tft.pushImage(x, y + row, w, 1, span); + pushSpan(x, y + row, w, span); } tft.setSwapBytes(previousSwap); return; @@ -58,19 +84,19 @@ void paintRoundRect(TFT_eSPI& tft, int x, int y, int w, int h, float radius, // only at corners. The cost drops from O(w*h) to O(radius^2) per-pixel work. const uint16_t fill = hasFill ? top : surface; if (h > 2 * ir) - tft.fillRect(x, y + ir, w, h - 2 * ir, fill); + fillClip(x, y + ir, w, h - 2 * ir, fill); if (w > 2 * ir) { - tft.fillRect(x + ir, y, w - 2 * ir, ir, fill); - tft.fillRect(x + ir, y + h - ir, w - 2 * ir, ir, fill); + fillClip(x + ir, y, w - 2 * ir, ir, fill); + fillClip(x + ir, y + h - ir, w - 2 * ir, ir, fill); } if (hasBorder) { if (w > 2 * ir) { - tft.drawFastHLine(x + ir, y, w - 2 * ir, border); - tft.drawFastHLine(x + ir, y + h - 1, w - 2 * ir, border); + fillClip(x + ir, y, w - 2 * ir, 1, border); + fillClip(x + ir, y + h - 1, w - 2 * ir, 1, border); } if (h > 2 * ir) { - tft.drawFastVLine(x, y + ir, h - 2 * ir, border); - tft.drawFastVLine(x + w - 1, y + ir, h - 2 * ir, border); + fillClip(x, y + ir, 1, h - 2 * ir, border); + fillClip(x + w - 1, y + ir, 1, h - 2 * ir, border); } } @@ -97,7 +123,7 @@ void paintRoundRect(TFT_eSPI& tft, int x, int y, int w, int h, float radius, pixel = gfx::blend565(pixel, border, outer - inner); span[col] = pixel; } - tft.pushImage(x + colStart, y + rowStart + row, ir, 1, span); + pushSpan(x + colStart, y + rowStart + row, ir, span); } } tft.setSwapBytes(previousSwap); @@ -154,24 +180,62 @@ esp32lua::Status Gui::setTheme(const std::string& theme) { int32_t Gui::color(int32_t r, int32_t g, int32_t b) const { return tft.color565(r, g, b); } -void Gui::clear(int32_t color) { tft.fillScreen(color); } +void Gui::clear(int32_t color) { out().fillScreen(color); } void Gui::fillRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t color) { - tft.fillRect(x, y, w, h, color); + out().fillRect(x - bufX, y - bufY, w, h, color); } void Gui::drawRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t color) { - tft.drawRect(x, y, w, h, color); + out().drawRect(x - bufX, y - bufY, w, h, color); } void Gui::drawPixel(int32_t x, int32_t y, int32_t color) { - tft.drawPixel(x, y, color); + out().drawPixel(x - bufX, y - bufY, color); +} + +// The painter opens one band at a time and closes it with present(). An 8bpp +// sprite of the whole panel (~140KB) will not allocate on this heap, so a band +// is a slice of it; refusing a second open keeps the first from being orphaned. +bool Gui::beginBuffer(int32_t x, int32_t y, int32_t w, int32_t h) { + if (buffer) + return false; + buffer = new TFT_eSprite(&tft); + // 16bpp, not 8: roundRect anti-aliases its corners by pushing 16-bit spans, + // which an 8bpp sprite cannot take. Two bytes a pixel means smaller bands, + // which the painter already assumes. + buffer->setColorDepth(16); + if (!buffer->createSprite(w, h)) { + delete buffer; + buffer = nullptr; + return false; + } + bufX = x; + bufY = y; + clipW = w; + clipH = h; + return true; +} + +void Gui::present() { + if (!buffer) + return; + buffer->pushSprite(bufX, bufY); + buffer->deleteSprite(); + delete buffer; + buffer = nullptr; + bufX = bufY = 0; + clipW = clipH = INT32_MAX; } void Gui::drawLine(int32_t x1, int32_t y1, int32_t x2, int32_t y2, int32_t color, int32_t width) { + x1 -= bufX; + x2 -= bufX; + y1 -= bufY; + y2 -= bufY; if (width <= 1) { - tft.drawLine(x1, y1, x2, y2, color); + out().drawLine(x1, y1, x2, y2, color); return; } - tft.drawWideLine(x1, y1, x2, y2, static_cast(width), color, color); + out().drawWideLine(x1, y1, x2, y2, static_cast(width), color, color); } void Gui::drawCircle(int32_t x, int32_t y, int32_t radius, int32_t color, @@ -179,14 +243,14 @@ void Gui::drawCircle(int32_t x, int32_t y, int32_t radius, int32_t color, for (int32_t ring = 0; ring < (width < 1 ? 1 : width); ring++) { const int32_t r = radius - ring; if (r > 0) - tft.drawCircle(x, y, r, color); + out().drawCircle(x - bufX, y - bufY, r, color); } } void Gui::fillCircle(int32_t x, int32_t y, int32_t radius, int32_t color, const int32_t* background) { - tft.fillSmoothCircle(x, y, radius, color, - background ? *background : TFT_WHITE); + out().fillSmoothCircle(x - bufX, y - bufY, radius, color, + background ? *background : TFT_WHITE); } void Gui::roundRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t radius, @@ -194,10 +258,10 @@ void Gui::roundRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t radius, const int32_t* bottom, const int32_t* border) { const uint16_t fillTop = top ? static_cast(*top) : 0; const uint16_t fillBottom = bottom ? static_cast(*bottom) : fillTop; - paintRoundRect(tft, x, y, w, h, static_cast(radius), + paintRoundRect(out(), x - bufX, y - bufY, w, h, static_cast(radius), static_cast(background), top != nullptr, fillTop, fillBottom, border != nullptr, - border ? static_cast(*border) : 0); + border ? static_cast(*border) : 0, clipW, clipH); } // Scanline fill: TFT_eSPI only offers triangles, and fanning a concave shape @@ -235,8 +299,8 @@ void Gui::fillPolygon(const int32_t* xs, const int32_t* ys, size_t count, } } for (size_t at = 0; at + 1 < found; at += 2) { - tft.drawFastHLine(crossings[at], y, crossings[at + 1] - crossings[at] + 1, - color); + out().drawFastHLine(crossings[at] - bufX, y - bufY, + crossings[at + 1] - crossings[at] + 1, color); } } } @@ -263,13 +327,13 @@ int32_t Gui::fontHeight(int32_t font, int32_t) const { void Gui::drawText(int32_t font, int32_t x, int32_t y, const std::string& text, int32_t color, int32_t, const int32_t* background) { - tft.setTextSize(scaleFor(font)); + out().setTextSize(scaleFor(font)); if (background) { - tft.setTextColor(color, *background); + out().setTextColor(color, *background); } else { - tft.setTextColor(color); + out().setTextColor(color); } - tft.drawString(text.c_str(), x, y); + out().drawString(text.c_str(), x - bufX, y - bufY); } } // namespace slate