feat: scrolling

This commit is contained in:
2026-08-06 09:05:14 -04:00
parent 0c0365fe45
commit 6e9cede336
5 changed files with 215 additions and 28 deletions
+39
View File
@@ -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
+67
View File
@@ -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
+17
View File
@@ -9,6 +9,7 @@
#include <XPT2046_Touchscreen.h>
#include <lua/providers.h>
#include <cstdint>
#include <map>
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 {
+91 -27
View File
@@ -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<float>(width), color, color);
out().drawWideLine(x1, y1, x2, y2, static_cast<float>(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<uint16_t>(*top) : 0;
const uint16_t fillBottom = bottom ? static_cast<uint16_t>(*bottom) : fillTop;
paintRoundRect(tft, x, y, w, h, static_cast<float>(radius),
paintRoundRect(out(), x - bufX, y - bufY, w, h, static_cast<float>(radius),
static_cast<uint16_t>(background), top != nullptr, fillTop,
fillBottom, border != nullptr,
border ? static_cast<uint16_t>(*border) : 0);
border ? static_cast<uint16_t>(*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