feat: scrolling

This commit is contained in:
2026-08-06 09:05:02 -04:00
parent 26f3fdb6e6
commit e5ed980294
9 changed files with 527 additions and 37 deletions
+18 -2
View File
@@ -85,6 +85,22 @@ function tree.hit(root, x, y) end
---@return integer h
function tree.getRect(id) end
---Pans a scrollable node's children, clamped to the content. The node is marked dirty, so the next draw repaints it.
---@param id NodeId
---@param x integer
---@param y integer
function tree.setScroll(id, x, y) end
---Returns a scrollable node's current offset, or zeroes.
---@param id NodeId
---@return integer x, integer y
function tree.getScroll(id) end
---Returns how far each axis can pan before the content's far edge reaches the box. Zero on an axis whose content fits.
---@param id NodeId
---@return integer maxX, integer maxY
function tree.getScrollRange(id) end
---Replaces a node's text and marks it for repaint.
---@param id NodeId
---@param text string
@@ -138,8 +154,8 @@ function tree.getFocus() end
---@return NodeId? focused Current focus when no candidate exists.
function tree.moveFocus(root, direction) end
---Registers the painter every custom node calls.
---@param painter fun(id: NodeId, x: integer, y: integer, w: integer, h: integer)
---Registers the painter every custom node calls. The clip arguments are the region of the node being painted now -- one band of a composited repaint -- so a painter can cull to it instead of redrawing itself once per band.
---@param painter fun(id: NodeId, x: integer, y: integer, w: integer, h: integer, clipX: integer, clipY: integer, clipW: integer, clipH: integer)
function tree.setPainter(painter) end
---Paints dirty nodes; the firmware owns publication to the physical display.
+36 -3
View File
@@ -27,8 +27,10 @@ local ui = {}
---@field on_enter? UiHandler
---@field on_exit? UiHandler
---@field on_click? UiHandler
---@field paint? fun(id: NodeId, x: integer, y: integer, w: integer, h: integer)
---@field paint? fun(id: NodeId, x: integer, y: integer, w: integer, h: integer, clipX: integer, clipY: integer, clipW: integer, clipH: integer)
---@field press_style? boolean False for a widget that paints its own press feedback.
---@field scrollX? boolean Children measure unbounded across, and the box pans horizontally.
---@field scrollY? boolean Children measure unbounded down, and the box pans vertically.
---@class UiConfirmSpec
---@field title string
@@ -135,10 +137,13 @@ local function clearState()
pressStyles = {}
end
tree.setPainter(function(id, x, y, w, h)
-- The clip is the slice of the node being repainted now, which for a composited repaint is
-- one band. A painter that ignores it still draws correctly; one that culls to it stops
-- redrawing its whole contents once per band it spans.
tree.setPainter(function(id, x, y, w, h, clipX, clipY, clipW, clipH)
local painter = painters[id]
if painter then
painter(id, x, y, w, h)
painter(id, x, y, w, h, clipX, clipY, clipW, clipH)
end
end)
@@ -308,6 +313,29 @@ function ui.invalidate(id)
tree.invalidate(id)
end
---Pans a scrollable node, clamped to its content. The node is marked dirty, so the next
---ui.draw() repaints it -- there is nothing for the app to draw and no handle to refresh,
---which is the whole point of scrolling in the tree rather than in a painter.
---@param id NodeId
---@param x integer
---@param y integer
function ui.setScroll(id, x, y)
tree.setScroll(id, x, y)
end
---@param id NodeId
---@return integer x, integer y
function ui.getScroll(id)
return tree.getScroll(id)
end
---How far each axis can pan. Zero on an axis whose content already fits.
---@param id NodeId
---@return integer maxX, integer maxY
function ui.getScrollRange(id)
return tree.getScrollRange(id)
end
---@param spec UiConfirmSpec
---@return NodeId
function ui.confirm(spec)
@@ -393,10 +421,15 @@ function ui.rebuild()
collectgarbage()
end
-- One GC step a frame, because the collector's default pace is the painter's problem: a
-- band buffer needs a contiguous block, and letting the heap double before a cycle lets
-- garbage take the block the band was going to get. Stepping keeps the sawtooth shallow
-- enough that beginBuffer() keeps succeeding instead of falling back to the panel.
function ui.draw()
if root then
tree.draw(root)
end
collectgarbage "step"
end
local function inside(id, x, y)
+115 -7
View File
@@ -36,6 +36,8 @@ enum Flag : uint8_t {
INTERACTIVE = 1 << 2, // has an on_press
DIRTY = 1 << 3,
PRESSED = 1 << 4,
SCROLL_X = 1 << 5, // children measure unbounded across, and pan horizontally
SCROLL_Y = 1 << 6,
};
enum SizeMode : uint8_t { AUTO, PX, FRACTION, FILL };
@@ -118,6 +120,15 @@ struct Spec {
uint16_t last = NONE; // tail of the child list, so append is not a walk
};
// A scroll container's pan offset and the size of what it holds. Sparse like
// styles, because a screen has one or two of these and Node has no room: the
// offsets are what setScroll() clamps against, and the content size is the only
// thing measure() learns that place() would otherwise throw away.
struct Scroll {
int16_t x = 0, y = 0;
int16_t contentW = 0, contentH = 0;
};
// Resistive panels land a few pixels off, so a hit box is larger than what was
// painted.
constexpr int SLOP = 4;
@@ -145,6 +156,7 @@ public:
labelAt.clear();
labels.clear();
styles.clear();
scrollState.clear();
error = nullptr;
}
@@ -215,6 +227,64 @@ public:
return (n.flags & INTERACTIVE) ? id : NONE;
}
bool scrolls(uint16_t id) const {
return (nodes[id].flags & (SCROLL_X | SCROLL_Y)) != 0;
}
const Scroll* scrollOf(uint16_t id) const {
for (size_t at = 0; at < scrollState.size(); at++) {
if (scrollState[at].first == id)
return &scrollState[at].second;
}
return nullptr;
}
// How far each axis can pan before the content's far edge reaches the box.
void scrollRange(uint16_t id, int& maxX, int& maxY) const {
maxX = maxY = 0;
const Scroll* s = scrollOf(id);
if (!s)
return;
if (nodes[id].flags & SCROLL_X)
maxX = s->contentW - nodes[id].w;
if (nodes[id].flags & SCROLL_Y)
maxY = s->contentH - nodes[id].h;
if (maxX < 0)
maxX = 0;
if (maxY < 0)
maxY = 0;
}
// Pans the subtree, clamped to the content. Applied as a delta to the stored
// coordinates rather than by placing again: place() reads Spec, which
// dropScratch() has already thrown away, and shifting keeps x/y in screen
// space so hit testing, getRect and every paint routine stay unchanged.
void setScroll(uint16_t id, int x, int y) {
if (!scrolls(id))
return;
Scroll* state = mutableScroll(id);
if (!state)
return;
int maxX = 0, maxY = 0;
scrollRange(id, maxX, maxY);
if (x < 0)
x = 0;
if (y < 0)
y = 0;
if (x > maxX)
x = maxX;
if (y > maxY)
y = maxY;
const int dx = x - state->x, dy = y - state->y;
if (dx == 0 && dy == 0)
return;
state->x = static_cast<int16_t>(x);
state->y = static_cast<int16_t>(y);
for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next)
shift(c, -dx, -dy);
nodes[id].flags |= DIRTY;
}
// Layout inputs are dead once place() has run. Callers drop them here rather
// than carrying ~20 bytes a node for the lifetime of a screen.
void dropScratch() {
@@ -302,6 +372,25 @@ private:
std::vector<uint16_t> labelAt;
std::vector<char> labels;
std::vector<std::pair<uint16_t, Style>> styles;
// Linear rather than the sorted search styles use: a screen has one or two
// scroll containers, so the binary search would cost more than the scan.
std::vector<std::pair<uint16_t, Scroll>> scrollState;
Scroll* mutableScroll(uint16_t id) {
for (size_t at = 0; at < scrollState.size(); at++) {
if (scrollState[at].first == id)
return &scrollState[at].second;
}
scrollState.push_back(std::make_pair(id, Scroll()));
return &scrollState.back().second;
}
void shift(uint16_t id, int dx, int dy) {
nodes[id].x = static_cast<int16_t>(nodes[id].x + dx);
nodes[id].y = static_cast<int16_t>(nodes[id].y + dy);
for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next)
shift(c, dx, dy);
}
void fail(const char* message) {
if (!error)
@@ -345,6 +434,22 @@ private:
// by its content cannot tell a child what fraction of it to take.
int innerH = h != UNKNOWN ? h - s.padT - s.padB : UNKNOWN;
// A scrolled axis is unbounded for the children, so they take their natural
// size and the content is free to overflow the box. The box itself still
// needs a size of its own on that axis -- one derived from the content
// would grow to fit it and never scroll.
const uint8_t scrollFlags = nodes[id].flags & (SCROLL_X | SCROLL_Y);
if (scrollFlags & SCROLL_X) {
if (w == UNKNOWN)
fail("scroll-x needs a width");
innerW = UNKNOWN;
}
if (scrollFlags & SCROLL_Y) {
if (h == UNKNOWN)
fail("scroll-y needs a height");
innerH = UNKNOWN;
}
int main = 0, cross = 0, count = 0;
for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next) {
measure(c, innerW, innerH);
@@ -373,16 +478,19 @@ private:
cross = row ? s.intrinsicH : s.intrinsicW;
}
Node& self = nodes[id];
int along = main + (row ? s.padL + s.padR : s.padT + s.padB);
int across = cross + (row ? s.padT + s.padB : s.padL + s.padR);
if (row) {
self.w = static_cast<int16_t>(w != UNKNOWN ? w : along);
self.h = static_cast<int16_t>(h != UNKNOWN ? h : across);
} else {
self.w = static_cast<int16_t>(w != UNKNOWN ? w : across);
self.h = static_cast<int16_t>(h != UNKNOWN ? h : along);
const int contentW = row ? along : across;
const int contentH = row ? across : along;
if (scrollFlags) {
Scroll* state = mutableScroll(id);
state->contentW = static_cast<int16_t>(contentW);
state->contentH = static_cast<int16_t>(contentH);
}
Node& self = nodes[id];
self.w = static_cast<int16_t>(w != UNKNOWN ? w : contentW);
self.h = static_cast<int16_t>(h != UNKNOWN ? h : contentH);
}
void place(uint16_t id, int x, int y, int w, int h) {
+30
View File
@@ -137,6 +137,36 @@ public:
// clean up ghosting all stay here. A live LCD has nothing pending and does
// nothing.
virtual void commit() = 0;
// Offscreen band for composited repaints. beginBuffer opens a RAM surface
// covering the screen rectangle (x, y, w, h); every subsequent draw keeps its
// screen coordinates (the driver translates into the band) and clips to it,
// so a caller re-walks the same tree per band without renumbering anything.
// present() blits the band back to (x, y) and returns drawing to the panel.
// Re-rasterizing into RAM and pushing once collapses a per-primitive bus
// transaction storm into a streamed write, which is what direct-to-panel
// drawing cannot make smooth. A driver with no spare RAM (or an e-ink panel
// that gains nothing) refuses the band, so the painter draws to the panel.
virtual bool beginBuffer(int32_t x, int32_t y, int32_t w, int32_t h) {
(void)x;
(void)y;
(void)w;
(void)h;
return false;
}
virtual void present() {}
// Clips every draw to this screen rectangle until clearClip(). The painter
// wraps a custom node in its own box, because a node owns its box and nothing
// else's: a band is usually taller than the node it covers, so a painter that
// draws outside itself -- a list row half scrolled off the top -- would
// otherwise land on whatever else that band covers. Not clipping is only safe
// while no custom node ever overdraws.
virtual void setClip(int32_t x, int32_t y, int32_t w, int32_t h) {
(void)x;
(void)y;
(void)w;
(void)h;
}
virtual void clearClip() {}
virtual void fillPolygon(const int32_t* xs, const int32_t* ys, size_t count,
int32_t color) = 0;
// Null coordinates centre the image; null bounds fall back to the panel size.
+52 -4
View File
@@ -177,6 +177,10 @@ int create(lua_State* state) {
flags |= ui::CAPTURE;
if (readFlag(state, 2, "interactive"))
flags |= ui::INTERACTIVE;
if (readFlag(state, 2, "scrollX"))
flags |= ui::SCROLL_X;
if (readFlag(state, 2, "scrollY"))
flags |= ui::SCROLL_Y;
lua_getfield(state, 2, "font");
const int32_t font = lua_isnoneornil(state, -1)
@@ -270,6 +274,27 @@ int getRect(lua_State* state) {
return 4;
}
int setScroll(lua_State* state) {
tree(state).setScroll(checkNode(state, 1), checkInt(state, 2),
checkInt(state, 3));
return 0;
}
int getScroll(lua_State* state) {
const ui::Scroll* scroll = tree(state).scrollOf(checkNode(state, 1));
lua_pushinteger(state, scroll ? scroll->x : 0);
lua_pushinteger(state, scroll ? scroll->y : 0);
return 2;
}
int getScrollRange(lua_State* state) {
int maxX = 0, maxY = 0;
tree(state).scrollRange(checkNode(state, 1), maxX, maxY);
lua_pushinteger(state, maxX);
lua_pushinteger(state, maxY);
return 2;
}
int setLabel(lua_State* state) {
const uint16_t id = checkNode(state, 1);
const char* text = luaL_checkstring(state, 2);
@@ -467,7 +492,8 @@ int moveFocus(lua_State* state) {
return 1;
}
void callPainter(void* context, uint16_t id, int x, int y, int w, int h) {
void callPainter(void* context, uint16_t id, int x, int y, int w, int h,
int clipX, int clipY, int clipW, int clipH) {
lua_State* state = static_cast<lua_State*>(context);
lua_getfield(state, LUA_REGISTRYINDEX, PAINTER_KEY);
if (!lua_isfunction(state, -1)) {
@@ -479,7 +505,11 @@ void callPainter(void* context, uint16_t id, int x, int y, int w, int h) {
lua_pushinteger(state, y);
lua_pushinteger(state, w);
lua_pushinteger(state, h);
if (lua_pcall(state, 5, 0, 0) != LUA_OK) {
lua_pushinteger(state, clipX);
lua_pushinteger(state, clipY);
lua_pushinteger(state, clipW);
lua_pushinteger(state, clipH);
if (lua_pcall(state, 9, 0, 0) != LUA_OK) {
Runtime::from(state)->log().write(
LogLevel::Error,
lua_tostring(state, -1) ? lua_tostring(state, -1) : "painter");
@@ -554,6 +584,21 @@ const luaL_Reg FUNCTIONS[] = {
// @return integer w
// @return integer h
{"getRect", getRect},
// --- Pans a scrollable node's children, clamped to the content. The node
// is marked dirty, so the next draw repaints it.
// @param id NodeId
// @param x integer
// @param y integer
{"setScroll", setScroll},
// --- Returns a scrollable node's current offset, or zeroes.
// @param id NodeId
// @return integer x, integer y
{"getScroll", getScroll},
// --- Returns how far each axis can pan before the content's far edge
// reaches the box. Zero on an axis whose content fits.
// @param id NodeId
// @return integer maxX, integer maxY
{"getScrollRange", getScrollRange},
// --- Replaces a node's text and marks it for repaint.
// @param id NodeId
// @param text string
@@ -597,9 +642,12 @@ const luaL_Reg FUNCTIONS[] = {
// @param direction NodeDirection
// @return NodeId|nil focused Current focus when no candidate exists.
{"moveFocus", moveFocus},
// --- Registers the painter every custom node calls.
// --- Registers the painter every custom node calls. The clip arguments are
// the region of the node being painted now -- one band of a composited
// repaint -- so a painter can cull to it instead of redrawing itself once
// per band.
// @param painter fun(id: NodeId, x: integer, y: integer, w: integer, h:
// integer)
// integer, clipX: integer, clipY: integer, clipW: integer, clipH: integer)
{"setPainter", setPainter},
// --- Paints dirty nodes; the firmware owns publication to the physical
// display.
File diff suppressed because one or more lines are too long
+220 -20
View File
@@ -1,10 +1,14 @@
#pragma once
// Painting the node tree through GuiProvider, so the same walk drives an LCD
// and an e-ink panel. A dirty node paints itself and dirties its children,
// because a parent's fill lands on top of whatever they drew; nothing tracks
// sub-regions, and a widget that wants to repaint part of itself is a CUSTOM
// node painting through the gui bindings.
// and an e-ink panel. The dirty region is composited a band at a time into an
// offscreen buffer and pushed once, so a full-pane repaint -- a scroll -- moves
// smoothly instead of stalling the bus per primitive. The tree is the display
// list: each band re-walks it and paints the nodes it touches, no recorded
// command stream. A driver that offers no buffer falls back to painting the
// dirty nodes straight to the panel, which is what an e-ink panel wants anyway.
// A widget repainting part of itself is still a CUSTOM node painting through
// the gui bindings; a custom painter is re-invoked per band it spans.
#include <lua/layout.h>
#include <lua/providers.h>
@@ -12,8 +16,21 @@
namespace esp32lua {
namespace ui {
// The node's own box, then the region of it being painted right now -- one band, or the
// whole node on the panel fallback. A painter that culls to the clip does its work once a
// frame instead of once per band it spans; one that ignores the extra arguments still
// paints correctly, because every write is clipped anyway.
typedef void (*CustomPainter)(void* context, uint16_t id, int x, int y, int w,
int h);
int h, int clipX, int clipY, int clipW,
int clipH);
// Starting band height. How much contiguous heap exists depends on the app, the
// orientation and how fragmented the heap already is, so this is a ceiling the
// painter shrinks from rather than a size it assumes: a band that will not
// allocate is halved and retried, down to MIN_BAND, before the repaint falls
// back to the panel.
constexpr int BAND_HEIGHT = 48;
constexpr int MIN_BAND = 8;
class Painter {
public:
@@ -23,22 +40,188 @@ public:
void* context = nullptr;
void draw(uint16_t id) {
if (tree.nodes[id].flags & DIRTY) {
paint(id);
tree.nodes[id].flags &= ~DIRTY;
for (uint16_t c = tree.nodes[id].first; c != NONE;
c = tree.nodes[c].next) {
tree.nodes[c].flags |= DIRTY;
}
}
for (uint16_t c = tree.nodes[id].first; c != NONE; c = tree.nodes[c].next)
draw(c);
Rect dirty;
collectDirty(id, false, dirty, Box::unbounded());
if (dirty.empty())
return;
if (!drawBanded(id, dirty))
drawDirect(id);
clearDirty(id);
}
private:
GuiProvider& gui;
Tree& tree;
struct Rect {
int minX = 1 << 30, minY = 1 << 30, maxX = -(1 << 30), maxY = -(1 << 30);
bool empty() const { return maxX <= minX || maxY <= minY; }
void add(int x, int y, int w, int h) {
if (x < minX)
minX = x;
if (y < minY)
minY = y;
if (x + w > maxX)
maxX = x + w;
if (y + h > maxY)
maxY = y + h;
}
};
// An edge-bounded rectangle, which a Rect built by union cannot express: the
// clip narrows as the walk descends and has to start meaning "no limit".
struct Box {
int x0, y0, x1, y1;
static Box unbounded() {
const Box b = {-(1 << 30), -(1 << 30), 1 << 30, 1 << 30};
return b;
}
bool empty() const { return x1 <= x0 || y1 <= y0; }
Box clipTo(int x, int y, int w, int h) const {
Box b = {x > x0 ? x : x0, y > y0 ? y : y0, x + w < x1 ? x + w : x1,
y + h < y1 ? y + h : y1};
return b;
}
};
// The clip in force, so a custom node inside a scroll container narrows the
// container's box instead of replacing it and painting over the chrome when
// it restores.
Box clip = Box::unbounded();
Box pushClip(const Box& next) {
const Box previous = clip;
clip = next;
gui.setClip(next.x0, next.y0, next.x1 - next.x0, next.y1 - next.y0);
return previous;
}
void popClip(const Box& previous) {
clip = previous;
if (previous.x0 == -(1 << 30) && previous.y0 == -(1 << 30))
gui.clearClip();
else
gui.setClip(previous.x0, previous.y0, previous.x1 - previous.x0,
previous.y1 - previous.y0);
}
static bool overlaps(const Node& n, int x, int y, int w, int h) {
return n.x < x + w && n.x + n.w > x && n.y < y + h && n.y + n.h > y;
}
// The region that will repaint: a dirty node and every descendant, because a
// dirty parent's fill lands over its children. Computed before painting so a
// band knows its extent without the dirty flags it is about to clear.
//
// Bounded by the enclosing scroll containers, because a scrolled subtree is
// as tall as its content: unclipped, dirtying a list of sixty rows would ask
// for a dirty region thousands of pixels tall and band the whole of it.
void collectDirty(uint16_t id, bool ancestorDirty, Rect& acc,
const Box& bounds) {
const Node& n = tree.nodes[id];
const bool dirty = ancestorDirty || (n.flags & DIRTY);
if (dirty) {
const Box visible = bounds.clipTo(n.x, n.y, n.w, n.h);
if (!visible.empty())
acc.add(visible.x0, visible.y0, visible.x1 - visible.x0,
visible.y1 - visible.y0);
}
const Box inner =
tree.scrolls(id) ? bounds.clipTo(n.x, n.y, n.w, n.h) : bounds;
if (inner.empty())
return;
for (uint16_t c = tree.nodes[id].first; c != NONE; c = tree.nodes[c].next)
collectDirty(c, dirty, acc, inner);
}
void clearDirty(uint16_t id) {
tree.nodes[id].flags &= ~DIRTY;
for (uint16_t c = tree.nodes[id].first; c != NONE; c = tree.nodes[c].next)
clearDirty(c);
}
// Composite the dirty region band by band. Returns false without drawing when
// the first band cannot be allocated, so the caller paints to the panel
// instead. A band is cleared to the root background because its buffer starts
// undefined; the nodes then paint their own fills over it in tree order.
bool drawBanded(uint16_t root, const Rect& dirty) {
const int x = dirty.minX;
const int w = dirty.maxX - dirty.minX;
const int32_t background = tree.inherited(root, S_BG).bg;
bool any = false;
int band = BAND_HEIGHT;
int y = dirty.minY;
while (y < dirty.maxY) {
const int h = y + band > dirty.maxY ? dirty.maxY - y : band;
if (gui.beginBuffer(x, y, w, h)) {
any = true;
gui.clear(background);
paintBand(root, x, y, w, h);
gui.present();
y += h;
} else if (band > MIN_BAND) {
band /= 2; // too big for this heap: retry the same strip, smaller
} else if (any) {
paintBand(root, x, y, w, h); // mid-frame failure: draw this strip direct
y += h;
} else {
return false; // nothing fits: caller uses the panel path
}
}
return true;
}
// Paint every node overlapping the band, parent before child so fills sit
// under their contents. Unlike drawDirect this ignores the dirty flag: the
// band's buffer was just cleared, so everything visible in it must be redrawn.
void paintBand(uint16_t id, int x, int y, int w, int h) {
const Node& n = tree.nodes[id];
if (overlaps(n, x, y, w, h))
paint(id, x, y, w, h);
if (n.first == NONE)
return;
// A scroll container's children are laid out past its edges, so the subtree
// is scissored to the box on the way in. Without it a row half scrolled off
// the top paints over whatever sits above the container.
const bool scissor = tree.scrolls(id);
Box previous = clip;
if (scissor) {
const Box inner = clip.clipTo(n.x, n.y, n.w, n.h);
if (inner.empty())
return;
previous = pushClip(inner);
}
for (uint16_t c = tree.nodes[id].first; c != NONE; c = tree.nodes[c].next)
paintBand(c, x, y, w, h);
if (scissor)
popClip(previous);
}
// Panel fallback: the original dirty-propagating walk, straight to the panel.
void drawDirect(uint16_t id) {
const Node& n = tree.nodes[id];
if (n.flags & DIRTY) {
paint(id, n.x, n.y, n.w, n.h);
for (uint16_t c = tree.nodes[id].first; c != NONE;
c = tree.nodes[c].next)
tree.nodes[c].flags |= DIRTY;
}
if (n.first == NONE)
return;
const bool scissor = tree.scrolls(id);
Box previous = clip;
if (scissor) {
const Box inner = clip.clipTo(n.x, n.y, n.w, n.h);
if (inner.empty())
return;
previous = pushClip(inner);
}
for (uint16_t c = tree.nodes[id].first; c != NONE; c = tree.nodes[c].next)
drawDirect(c);
if (scissor)
popClip(previous);
}
// What a node sits on, which is not what it fills. Derived rather than
// stored, because a node cannot be told what is behind it: a dialog layer
// paints nothing, so its card blends into the dimmed content two levels up,
@@ -56,7 +239,7 @@ private:
return tree.inherited(root, S_BG).bg;
}
void paint(uint16_t id) {
void paint(uint16_t id, int clipX, int clipY, int clipW, int clipH) {
const Node& n = tree.nodes[id];
switch (n.type) {
case BUTTON:
@@ -65,13 +248,29 @@ private:
case TEXT:
paintText(id);
break;
case CUSTOM:
case CUSTOM: {
// Cleared first, because a custom painter draws what it wants and nothing
// knows what it drew last time.
gui.fillRect(n.x, n.y, n.w, n.h, tree.inherited(id, S_BG).bg);
// knows what it drew last time. Only the band's slice of the node is
// cleared, because each band clears its own before repainting it.
const int x0 = n.x > clipX ? n.x : clipX;
const int y0 = n.y > clipY ? n.y : clipY;
const int nx1 = n.x + n.w, ny1 = n.y + n.h;
const int x1 = nx1 < clipX + clipW ? nx1 : clipX + clipW;
const int y1 = ny1 < clipY + clipH ? ny1 : clipY + clipH;
if (x1 <= x0 || y1 <= y0)
break;
// Scissored to the node, not just to the band: a custom painter draws what
// it likes and a band is usually taller than the node, so the overflow of
// a row half scrolled off the top would otherwise paint over the chrome
// above it. Culling cannot replace this -- the row is meant to be drawn,
// just cut off at the node's edge.
const Box previous = pushClip(clip.clipTo(x0, y0, x1 - x0, y1 - y0));
gui.fillRect(x0, y0, x1 - x0, y1 - y0, tree.inherited(id, S_BG).bg);
if (custom)
custom(context, id, n.x, n.y, n.w, n.h);
custom(context, id, n.x, n.y, n.w, n.h, x0, y0, x1 - x0, y1 - y0);
popClip(previous);
break;
}
default:
paintBox(id);
break;
@@ -80,6 +279,7 @@ private:
paintFocus(id);
}
void paintBox(uint16_t id) {
const Node& n = tree.nodes[id];
const Style* own = tree.styleOf(id);
+5
View File
@@ -162,6 +162,11 @@ struct Gui : GuiProvider {
return (r << 16) | (g << 8) | b;
}
void clear(int32_t) override { trace += "clear;"; }
void setClip(int32_t x, int32_t y, int32_t w, int32_t h) override {
trace += "clip(" + std::to_string(x) + "," + std::to_string(y) + "," +
std::to_string(w) + "," + std::to_string(h) + ");";
}
void clearClip() override { trace += "unclip;"; }
void fillRect(int32_t, int32_t, int32_t, int32_t, int32_t) override {
trace += "fillRect;";
}
+50
View File
@@ -198,6 +198,56 @@ int main() {
"tree.draw(root)\n"
"assert(painted == 320)");
// A scrolling box: children measure past its edges, panning moves them and is
// clamped to the content, and what is scrolled out of the box is neither hit
// nor painted outside it.
bench.gui.trace.clear();
run(state,
"tree.reset()\n"
"local list = tree.create(nil, {type = 'box', w = 'fill', h = 'fill',\n"
" scrollX = true, scrollY = true})\n"
"local rows = {}\n"
"for i = 1, 5 do rows[i] = tree.create(list, {type = 'box', w = 400, h = "
"100, interactive = true}) end\n"
"tree.setStyle(list, {background = 0xFFFFFF, fill = 0xFFFFFF, border = "
"0x333333})\n"
// The box is the panel; the content is deliberately larger on both axes.
"assert(tree.layout(list, 0, 0, 320, 240))\n"
"local maxX, maxY = tree.getScrollRange(list)\n"
"assert(maxX == 400 - 320, 'content is wider than the box')\n"
"assert(maxY == 5 * 100 - 240, 'content is taller than the box')\n"
// Panning shifts the children, and getRect keeps answering screen space.
"tree.setScroll(list, 30, 150)\n"
"local x, y = tree.getRect(rows[1])\n"
"assert(x == -30 and y == -150, 'row moved by the scroll')\n"
"local sx, sy = tree.getScroll(list)\n"
"assert(sx == 30 and sy == 150)\n"
// The box itself does not move, only what it holds.
"local bx, by = tree.getRect(list)\n"
"assert(bx == 0 and by == 0)\n"
// Row 1 is scrolled above the box, so a tap at the top hits row 2.
"assert(tree.hit(list, 10, 10) == rows[2], 'scrolled-out row is not hit')\n"
// Clamped, never past the content's far edge or before its start.
"tree.setScroll(list, 9999, 9999)\n"
"local cx, cy = tree.getScroll(list)\n"
"assert(cx == maxX and cy == maxY, 'clamped to the content')\n"
"tree.setScroll(list, -50, -50)\n"
"local zx, zy = tree.getScroll(list)\n"
"assert(zx == 0 and zy == 0, 'clamped at the origin')\n"
"tree.draw(list)\n"
"tree.dropScratch()");
// The subtree is scissored to the box, so a row hanging past it cannot paint
// over whatever sits outside.
assert(bench.gui.trace.find("clip(0,0,320,240);") != std::string::npos);
assert(bench.gui.trace.find("unclip;") != std::string::npos);
// A scrolled axis has no size to hand down, so the box needs one of its own.
expectError(state, "tree.reset()\n"
"local l = tree.create(nil, {type = 'box', scrollY = "
"true})\n"
"tree.create(l, {type = 'box', w = 10, h = 10})\n"
"assert(tree.layout(l, 0, 0, 320, 240))");
// A timer firing inside draw is still one batch.
run(state, "function draw() timer.after(1, function() end) end\n"
"nested = timer.after(1, function() draw() end)");