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
+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.