initial commit
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
#pragma once
|
||||
|
||||
// The widget tree: structure, block-flow layout, labels and style, over a flat node
|
||||
// arena. Free of Arduino headers so test/ui_layout_test.cpp can exercise it on the host.
|
||||
// Ported from the Lua toolkit's Component:measure/place, whose semantics the tests
|
||||
// still describe.
|
||||
//
|
||||
// The tree is split across two arenas because the halves have different lifetimes. A
|
||||
// Node holds what hit testing and repainting need forever: 16 bytes. A Spec holds what
|
||||
// only measure() and place() read -- requested sizes, padding, gap, alignment -- and is
|
||||
// dropped when the pass ends. Re-layout rebuilds from Lua rather than retaining ~20
|
||||
// bytes per node against a rotation nobody measures in milliseconds.
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace esp32lua {
|
||||
namespace ui {
|
||||
|
||||
constexpr uint16_t NONE = 0xFFFF;
|
||||
|
||||
// Distinguishes "no constraint" from a real zero, which a plain int cannot: an auto-sized
|
||||
// parent genuinely has no width to hand down, and a child asking for a fraction of it is
|
||||
// an error rather than a zero-width silence.
|
||||
constexpr int UNKNOWN = INT32_MIN;
|
||||
|
||||
enum Type : uint8_t { BOX, TEXT, BUTTON, CUSTOM };
|
||||
|
||||
enum Flag : uint8_t {
|
||||
ROW = 1 << 0, // main axis is horizontal
|
||||
CAPTURE = 1 << 1, // swallows the taps its children missed
|
||||
INTERACTIVE = 1 << 2, // has an on_press
|
||||
DIRTY = 1 << 3,
|
||||
PRESSED = 1 << 4,
|
||||
};
|
||||
|
||||
enum SizeMode : uint8_t { AUTO, PX, FRACTION, FILL };
|
||||
enum Align : uint8_t { START, CENTER, END, BETWEEN };
|
||||
|
||||
// Which roles a style states for itself. Anything unset is answered by the nearest
|
||||
// ancestor that does state it, so styling stays in one place and a node that names no
|
||||
// colours costs no bytes at all.
|
||||
enum StyleField : uint16_t {
|
||||
S_COLOR = 1 << 0,
|
||||
// The background a node offers its descendants to draw text on, which is not the same
|
||||
// as the fill it paints: a dialog layer hands the lit palette down while painting
|
||||
// nothing itself. What is physically behind a node is derived at paint time, never set.
|
||||
S_BG = 1 << 1,
|
||||
S_FILL = 1 << 2,
|
||||
S_BORDER = 1 << 3,
|
||||
S_FACE = 1 << 4,
|
||||
S_PRESSED_FACE = 1 << 5,
|
||||
S_PRESSED_COLOR = 1 << 6,
|
||||
S_FOCUS_COLOR = 1 << 7,
|
||||
S_RADIUS = 1 << 8,
|
||||
S_FONT = 1 << 9,
|
||||
S_TEXT_STYLE = 1 << 10,
|
||||
};
|
||||
|
||||
// Colours are whatever gui.color() returned, so the panel's own encoding.
|
||||
struct Style {
|
||||
int32_t color = 0;
|
||||
int32_t bg = 0;
|
||||
int32_t fill = 0;
|
||||
int32_t border = 0;
|
||||
int32_t face = 0;
|
||||
int32_t pressedFace = 0;
|
||||
int32_t pressedColor = 0;
|
||||
int32_t focusColor = 0;
|
||||
int32_t font = 0;
|
||||
int32_t textStyle = 0;
|
||||
uint8_t radius = 6;
|
||||
uint16_t set = 0;
|
||||
};
|
||||
|
||||
// FRACTION is per-mille rather than a float: 0.85 of 320 is 272 either way, and the
|
||||
// firmware has no business rounding differently to the host.
|
||||
struct Size {
|
||||
SizeMode mode = AUTO;
|
||||
int16_t value = 0;
|
||||
|
||||
static Size make(SizeMode mode, int16_t value) {
|
||||
Size size;
|
||||
size.mode = mode;
|
||||
size.value = value;
|
||||
return size;
|
||||
}
|
||||
static Size px(int16_t v) { return make(PX, v); }
|
||||
static Size fraction(int16_t permille) { return make(FRACTION, permille); }
|
||||
static Size fill() { return make(FILL, 0); }
|
||||
};
|
||||
|
||||
// Persistent. w/h hold the measured size between measure() and place(), and the final
|
||||
// rect afterwards, because the two are never needed at once.
|
||||
struct Node {
|
||||
int16_t x = 0, y = 0, w = 0, h = 0;
|
||||
uint16_t first = NONE, next = NONE, parent = NONE;
|
||||
uint8_t type = BOX;
|
||||
uint8_t flags = 0;
|
||||
};
|
||||
|
||||
// Scratch. Lives only for the duration of a build plus its layout pass.
|
||||
struct Spec {
|
||||
Size w, h;
|
||||
Size atX, atY;
|
||||
bool absolute = false;
|
||||
int16_t intrinsicW = 0, intrinsicH = 0; // content size of a leaf, e.g. a text run
|
||||
uint8_t padT = 0, padR = 0, padB = 0, padL = 0;
|
||||
uint8_t gap = 0;
|
||||
Align align = START;
|
||||
Align justify = START;
|
||||
uint16_t last = NONE; // tail of the child list, so append is not a walk
|
||||
};
|
||||
|
||||
// Resistive panels land a few pixels off, so a hit box is larger than what was painted.
|
||||
constexpr int SLOP = 4;
|
||||
|
||||
constexpr uint16_t NO_LABEL = 0xFFFF;
|
||||
|
||||
class Tree {
|
||||
public:
|
||||
std::vector<Node> nodes;
|
||||
std::vector<Spec> specs;
|
||||
const char* error = nullptr;
|
||||
uint16_t focus = NONE;
|
||||
|
||||
void reset() {
|
||||
focus = NONE;
|
||||
nodes.clear();
|
||||
specs.clear();
|
||||
labelAt.clear();
|
||||
labels.clear();
|
||||
styles.clear();
|
||||
error = nullptr;
|
||||
}
|
||||
|
||||
uint16_t add(uint16_t parent, const Spec& spec, uint8_t type = BOX, uint8_t flags = 0) {
|
||||
// A tree whose scratch has been dropped cannot be extended: its layout inputs are
|
||||
// gone, so building again is a new screen by definition. Self-healing rather than
|
||||
// advisory, because the alternative is a spec list that no longer indexes the nodes.
|
||||
if (specs.size() != nodes.size()) reset();
|
||||
uint16_t id = static_cast<uint16_t>(nodes.size());
|
||||
nodes.push_back(Node());
|
||||
specs.push_back(spec);
|
||||
labelAt.push_back(NO_LABEL);
|
||||
nodes[id].type = type;
|
||||
nodes[id].flags = flags;
|
||||
nodes[id].parent = parent;
|
||||
if (parent != NONE) attach(parent, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
// Lua evaluates inner constructors first, so a child exists before the box that holds
|
||||
// it. Adopting afterwards is what lets each spec table be garbage the moment its node
|
||||
// is created, instead of a whole screen's worth of them living until the end of a build.
|
||||
void attach(uint16_t parent, uint16_t child) {
|
||||
nodes[child].parent = parent;
|
||||
uint16_t tail = specs[parent].last;
|
||||
if (tail == NONE) {
|
||||
nodes[parent].first = child;
|
||||
} else {
|
||||
nodes[tail].next = child;
|
||||
}
|
||||
specs[parent].last = child;
|
||||
}
|
||||
|
||||
bool layout(uint16_t root, int x, int y, int w, int h) {
|
||||
error = nullptr;
|
||||
measure(root, w, h);
|
||||
if (error) return false;
|
||||
place(root, x, y, w, h);
|
||||
return error == nullptr;
|
||||
}
|
||||
|
||||
// Deepest interactive node wins, so a tappable child beats its tappable parent. The
|
||||
// list is singly linked, so "last match walking forward" stands in for "first match
|
||||
// walking backward"; they name the same node.
|
||||
uint16_t hit(uint16_t id, int px, int py) const {
|
||||
const Node& n = nodes[id];
|
||||
if (px < n.x - SLOP || px >= n.x + n.w + SLOP) return NONE;
|
||||
if (py < n.y - SLOP || py >= n.y + n.h + SLOP) return NONE;
|
||||
|
||||
uint16_t found = NONE;
|
||||
for (uint16_t c = n.first; c != NONE; c = nodes[c].next) {
|
||||
uint16_t inner = hit(c, px, py);
|
||||
if (inner != NONE) found = inner;
|
||||
}
|
||||
if (found != NONE) return found;
|
||||
if (n.flags & CAPTURE) return id;
|
||||
return (n.flags & INTERACTIVE) ? id : NONE;
|
||||
}
|
||||
|
||||
// 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() {
|
||||
specs.clear();
|
||||
specs.shrink_to_fit();
|
||||
}
|
||||
|
||||
// Labels share one NUL-separated arena, so a text node costs two bytes plus its
|
||||
// characters rather than a string object.
|
||||
//
|
||||
// ponytail: a longer label appends and abandons the old bytes. The clock repaints
|
||||
// every second at a fixed width, which overwrites in place, so the arena only grows
|
||||
// when a label genuinely gets longer. Compact on rebuild if some app proves otherwise.
|
||||
void setLabel(uint16_t id, const char* text) {
|
||||
size_t length = strlen(text);
|
||||
uint16_t at = labelAt[id];
|
||||
if (at != NO_LABEL && strlen(&labels[at]) >= length) {
|
||||
memcpy(&labels[at], text, length + 1);
|
||||
return;
|
||||
}
|
||||
labelAt[id] = static_cast<uint16_t>(labels.size());
|
||||
labels.insert(labels.end(), text, text + length + 1);
|
||||
}
|
||||
|
||||
const char* label(uint16_t id) const {
|
||||
uint16_t at = labelAt[id];
|
||||
return at == NO_LABEL ? nullptr : &labels[at];
|
||||
}
|
||||
|
||||
size_t footprint() const {
|
||||
return nodes.size() * sizeof(Node) + labelAt.size() * sizeof(uint16_t) + labels.size() +
|
||||
styles.size() * sizeof(styles[0]);
|
||||
}
|
||||
|
||||
// Styles are sparse because inheritance means almost every node states nothing: a
|
||||
// screen's root carries the palette and a handful of nodes override one role. Ids are
|
||||
// handed out in increasing order during a build, so appends keep the list sorted and
|
||||
// lookup is a binary search.
|
||||
Style& styleFor(uint16_t id) {
|
||||
size_t low = 0, high = styles.size();
|
||||
while (low < high) {
|
||||
size_t mid = (low + high) / 2;
|
||||
if (styles[mid].first < id) {
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
if (low < styles.size() && styles[low].first == id) return styles[low].second;
|
||||
return styles.insert(styles.begin() + low, std::make_pair(id, Style()))->second;
|
||||
}
|
||||
|
||||
// The nearest style at or above `id` that states `field`, or a default one if nothing
|
||||
// does. Returning the whole style lets a caller read the pair a role comes in.
|
||||
const Style& inherited(uint16_t id, uint16_t field) const {
|
||||
static const Style fallback;
|
||||
for (uint16_t n = id; n != NONE; n = nodes[n].parent) {
|
||||
const Style* style = styleOf(n);
|
||||
if (style && (style->set & field)) return *style;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const Style* styleOf(uint16_t id) const {
|
||||
size_t low = 0, high = styles.size();
|
||||
while (low < high) {
|
||||
size_t mid = (low + high) / 2;
|
||||
if (styles[mid].first < id) {
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
return (low < styles.size() && styles[low].first == id) ? &styles[low].second : nullptr;
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<uint16_t> labelAt;
|
||||
std::vector<char> labels;
|
||||
std::vector<std::pair<uint16_t, Style> > styles;
|
||||
|
||||
void fail(const char* message) {
|
||||
if (!error) error = message;
|
||||
}
|
||||
|
||||
int resolve(Size size, int span) {
|
||||
switch (size.mode) {
|
||||
case AUTO:
|
||||
return UNKNOWN;
|
||||
case PX:
|
||||
return size.value;
|
||||
case FILL:
|
||||
if (span == UNKNOWN) {
|
||||
fail("fill inside an auto-sized parent");
|
||||
return 0;
|
||||
}
|
||||
return span;
|
||||
case FRACTION:
|
||||
if (span == UNKNOWN) {
|
||||
fail("fraction inside an auto-sized parent");
|
||||
return 0;
|
||||
}
|
||||
return span * size.value / 1000;
|
||||
}
|
||||
return UNKNOWN;
|
||||
}
|
||||
|
||||
void measure(uint16_t id, int availW, int availH) {
|
||||
// By value, and re-index after recursion: measuring a child can push nodes, and a
|
||||
// vector that grows moves every reference taken before it.
|
||||
const Spec s = specs[id];
|
||||
const bool row = (nodes[id].flags & ROW) != 0;
|
||||
int w = resolve(s.w, availW);
|
||||
int h = resolve(s.h, availH);
|
||||
|
||||
int innerW = w != UNKNOWN ? w - s.padL - s.padR
|
||||
: (availW != UNKNOWN ? availW - s.padL - s.padR : UNKNOWN);
|
||||
// Height is only handed down when this node was given one. A parent sized by its
|
||||
// content cannot tell a child what fraction of it to take.
|
||||
int innerH = h != UNKNOWN ? h - s.padT - s.padB : 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);
|
||||
// Absolutely placed children are measured, because they still need a size, but they
|
||||
// take no part in the flow their siblings share. The Lua original counted them here
|
||||
// and excluded them in place(); nothing depended on the disagreement.
|
||||
if (specs[c].absolute) continue;
|
||||
const Node& child = nodes[c];
|
||||
if (count) main += s.gap;
|
||||
if (row) {
|
||||
main += child.w;
|
||||
if (child.h > cross) cross = child.h;
|
||||
} else {
|
||||
main += child.h;
|
||||
if (child.w > cross) cross = child.w;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
if (count == 0) {
|
||||
main = row ? s.intrinsicW : s.intrinsicH;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
void place(uint16_t id, int x, int y, int w, int h) {
|
||||
const Spec s = specs[id];
|
||||
bool row = (nodes[id].flags & ROW) != 0;
|
||||
{
|
||||
Node& self = nodes[id];
|
||||
self.x = static_cast<int16_t>(x);
|
||||
self.y = static_cast<int16_t>(y);
|
||||
self.w = static_cast<int16_t>(w);
|
||||
self.h = static_cast<int16_t>(h);
|
||||
self.flags |= DIRTY;
|
||||
}
|
||||
|
||||
int cx = x + s.padL, cy = y + s.padT;
|
||||
int cw = w - s.padL - s.padR, ch = h - s.padT - s.padB;
|
||||
|
||||
// Main-axis distribution, CSS justify-content minus the modes nothing asks for.
|
||||
// Absolutely placed children take no part in the flow.
|
||||
int flowing = 0, used = 0;
|
||||
for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next) {
|
||||
if (specs[c].absolute) continue;
|
||||
flowing++;
|
||||
used += row ? nodes[c].w : nodes[c].h;
|
||||
}
|
||||
if (flowing > 1) used += (flowing - 1) * s.gap;
|
||||
int slack = (row ? cw : ch) - used;
|
||||
if (slack < 0) slack = 0;
|
||||
|
||||
int offset = 0, spread = 0;
|
||||
if (s.justify == END) {
|
||||
offset = slack;
|
||||
} else if (s.justify == CENTER) {
|
||||
offset = slack / 2;
|
||||
} else if (s.justify == BETWEEN && flowing > 1) {
|
||||
spread = slack / (flowing - 1);
|
||||
}
|
||||
|
||||
for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next) {
|
||||
const Spec cs = specs[c];
|
||||
int childW = nodes[c].w, childH = nodes[c].h;
|
||||
// Cross axis fills the parent unless the child asked for a size, like CSS blocks.
|
||||
if (row) {
|
||||
if (cs.h.mode == AUTO) childH = ch;
|
||||
} else {
|
||||
if (cs.w.mode == AUTO) childW = cw;
|
||||
}
|
||||
|
||||
int px, py;
|
||||
if (cs.absolute) {
|
||||
px = cx + resolve(cs.atX, cw);
|
||||
py = cy + resolve(cs.atY, ch);
|
||||
} else if (row) {
|
||||
px = cx + offset;
|
||||
py = cy;
|
||||
if (s.align == CENTER) {
|
||||
py += (ch - childH) / 2;
|
||||
} else if (s.align == END) {
|
||||
py += ch - childH;
|
||||
}
|
||||
offset += childW + s.gap + spread;
|
||||
} else {
|
||||
px = cx;
|
||||
py = cy + offset;
|
||||
if (s.align == CENTER) {
|
||||
px += (cw - childW) / 2;
|
||||
} else if (s.align == END) {
|
||||
px += cw - childW;
|
||||
}
|
||||
offset += childH + s.gap + spread;
|
||||
}
|
||||
place(c, px, py, childW, childH);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
} // namespace esp32lua
|
||||
@@ -0,0 +1,237 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace esp32lua {
|
||||
|
||||
// Every value crossing this seam is int32_t because the interpreter is built with
|
||||
// LUA_32BITS: the ESP32 is a 32-bit core with no hardware float64, so a wider lua_Integer
|
||||
// would cost size and speed on every value in the heap. The ceilings that follows from it
|
||||
// are sys.getMillis() wrapping after ~24.9 days of uptime and file offsets stopping at
|
||||
// 2 GB, neither of which a battery-powered handheld reading an SD card reaches.
|
||||
struct Status {
|
||||
bool ok;
|
||||
std::string error;
|
||||
|
||||
static Status success() { return {true, {}}; }
|
||||
static Status failure(const std::string& error) { return {false, error}; }
|
||||
};
|
||||
|
||||
enum class LogLevel { Debug, Info, Error };
|
||||
|
||||
class LogProvider {
|
||||
public:
|
||||
virtual ~LogProvider() = default;
|
||||
virtual void write(LogLevel level, const std::string& message) = 0;
|
||||
};
|
||||
|
||||
struct MemoryInfo {
|
||||
int32_t freeBytes;
|
||||
int32_t totalBytes;
|
||||
int32_t largestFreeBlock;
|
||||
};
|
||||
|
||||
class SettingsProvider {
|
||||
public:
|
||||
virtual ~SettingsProvider() = default;
|
||||
virtual int32_t rotation() const = 0;
|
||||
virtual Status setRotation(int32_t degrees) = 0;
|
||||
virtual std::string timezone() const = 0;
|
||||
virtual Status setTimezone(const std::string& timezone) = 0;
|
||||
};
|
||||
|
||||
// Only what the firmware alone can answer. App identity, titles, data paths, feature reporting
|
||||
// and navigation are the runtime's, because it owns app loading and knows which providers exist.
|
||||
class SysProvider {
|
||||
public:
|
||||
virtual ~SysProvider() = default;
|
||||
virtual int32_t millis() const = 0;
|
||||
virtual MemoryInfo memory() const = 0;
|
||||
virtual bool isClockSynced() const = 0;
|
||||
};
|
||||
|
||||
// Scripts are streamed rather than slurped: a whole module in one buffer needs that many bytes
|
||||
// contiguous, and once WiFi is up the largest free block is far smaller than the free heap.
|
||||
class FileReader {
|
||||
public:
|
||||
virtual ~FileReader() = default;
|
||||
// Bytes read, zero at end of file, negative on failure.
|
||||
virtual int32_t read(char* out, int32_t maxBytes) = 0;
|
||||
};
|
||||
|
||||
class FsProvider {
|
||||
public:
|
||||
virtual ~FsProvider() = default;
|
||||
// Null when the path is missing or is a directory. The caller owns the reader.
|
||||
virtual FileReader* openRead(const std::string& path) = 0;
|
||||
virtual bool exists(const std::string& path) const = 0;
|
||||
virtual Status fileSize(const std::string& path, int32_t& size) const = 0;
|
||||
virtual Status listDirs(const std::string& path, std::vector<std::string>& names) const = 0;
|
||||
virtual Status listFiles(const std::string& path, std::vector<std::string>& names) const = 0;
|
||||
virtual Status mkdir(const std::string& path) = 0;
|
||||
virtual Status readFile(const std::string& path, int32_t maxBytes, std::string& content) const = 0;
|
||||
// A line past the end reports ok with `found` false, which the binding returns as a bare nil.
|
||||
virtual Status readLineAt(const std::string& path, int32_t offset, int32_t maxBytes, bool& found,
|
||||
std::string& line, int32_t& nextOffset) const = 0;
|
||||
virtual Status remove(const std::string& path) = 0;
|
||||
virtual Status removeTree(const std::string& path) = 0;
|
||||
virtual Status rename(const std::string& source, const std::string& destination) = 0;
|
||||
virtual Status writeFile(const std::string& path, const std::string& content) = 0;
|
||||
};
|
||||
|
||||
// Native identifiers the firmware assigns to the portable font roles and text styles.
|
||||
struct FontIds {
|
||||
int32_t small;
|
||||
int32_t ui;
|
||||
int32_t body;
|
||||
int32_t large;
|
||||
int32_t styleNormal;
|
||||
int32_t styleBold;
|
||||
};
|
||||
|
||||
class GuiProvider {
|
||||
public:
|
||||
virtual ~GuiProvider() = default;
|
||||
virtual FontIds fonts() const = 0;
|
||||
virtual int32_t width() const = 0;
|
||||
virtual int32_t height() const = 0;
|
||||
virtual int32_t rotation() const = 0;
|
||||
virtual void setRotation(int32_t degrees) = 0;
|
||||
virtual int32_t color(int32_t r, int32_t g, int32_t b) const = 0;
|
||||
virtual void clear(int32_t color) = 0;
|
||||
virtual void fillRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t color) = 0;
|
||||
virtual void drawRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t color) = 0;
|
||||
virtual void drawLine(int32_t x1, int32_t y1, int32_t x2, int32_t y2, int32_t color, int32_t width) = 0;
|
||||
virtual void drawPixel(int32_t x, int32_t y, int32_t color) = 0;
|
||||
virtual void drawCircle(int32_t x, int32_t y, int32_t radius, int32_t color, int32_t width) = 0;
|
||||
virtual void fillCircle(int32_t x, int32_t y, int32_t radius, int32_t color, const int32_t* background) = 0;
|
||||
// Fill, gradient, and border come from one distance field, so they cannot disagree at the
|
||||
// corners. A null top paints no fill, a null border paints no outline, and a panel with no
|
||||
// gradient of its own is free to ignore `bottom` the way color() quantizes to grayscale.
|
||||
virtual void roundRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t radius, int32_t background,
|
||||
const int32_t* top, const int32_t* bottom, const int32_t* border) = 0;
|
||||
// Hands the app the whole panel, including whatever chrome the firmware paints.
|
||||
virtual void setFullscreen(bool on) = 0;
|
||||
// Applies everything drawn since the last commit. The runtime supplies only the timing -- the
|
||||
// end of a callback batch -- because that is the one fact a driver cannot know; which region to
|
||||
// touch, which waveform, and whether to clean up ghosting all stay here. A live LCD has nothing
|
||||
// pending and does nothing.
|
||||
virtual void commit() = 0;
|
||||
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.
|
||||
virtual Status drawBmp(const std::string& path, const int32_t* x, const int32_t* y, const int32_t* maxWidth,
|
||||
const int32_t* maxHeight) = 0;
|
||||
virtual int32_t textWidth(int32_t font, const std::string& text, int32_t style) const = 0;
|
||||
virtual int32_t fontHeight(int32_t font, int32_t style) const = 0;
|
||||
virtual void drawText(int32_t font, int32_t x, int32_t y, const std::string& text, int32_t color, int32_t style,
|
||||
const int32_t* background) = 0;
|
||||
};
|
||||
|
||||
struct HttpHeader {
|
||||
std::string name;
|
||||
std::string value;
|
||||
};
|
||||
|
||||
struct HttpResponse {
|
||||
int32_t status;
|
||||
std::string body;
|
||||
};
|
||||
|
||||
struct HttpDownload {
|
||||
int32_t maxBytes;
|
||||
int32_t expectedSize; // Zero when the caller did not declare one.
|
||||
std::string sha256; // Empty when the caller did not declare one.
|
||||
};
|
||||
|
||||
class HttpProvider {
|
||||
public:
|
||||
virtual ~HttpProvider() = default;
|
||||
virtual Status request(const std::string& method, const std::string& url, const std::string& body,
|
||||
const std::vector<HttpHeader>& headers, int32_t maxBytes, HttpResponse& response) = 0;
|
||||
virtual Status download(const std::string& url, const std::string& destination, const HttpDownload& options,
|
||||
int32_t& bytesWritten) = 0;
|
||||
};
|
||||
|
||||
using TimerId = int32_t;
|
||||
|
||||
// The firmware schedules deadlines and calls Runtime::callTimer() on the Lua thread; the
|
||||
// runtime owns the identifiers and the retained callbacks.
|
||||
class TimerProvider {
|
||||
public:
|
||||
virtual ~TimerProvider() = default;
|
||||
virtual Status schedule(TimerId id, int32_t intervalMs, bool repeating) = 0;
|
||||
virtual void cancel(TimerId id) = 0;
|
||||
};
|
||||
|
||||
struct WifiNetwork {
|
||||
std::string ssid;
|
||||
int32_t rssi;
|
||||
bool secure;
|
||||
};
|
||||
|
||||
struct WifiStatus {
|
||||
std::string state; // One of the WifiState alias values.
|
||||
std::string ssid;
|
||||
std::string ip;
|
||||
int32_t rssi;
|
||||
};
|
||||
|
||||
class WifiProvider {
|
||||
public:
|
||||
virtual ~WifiProvider() = default;
|
||||
virtual Status scan(std::vector<WifiNetwork>& networks) = 0;
|
||||
// Null credentials reconnect whatever the firmware has saved.
|
||||
virtual Status connect(const std::string* ssid, const std::string* password) = 0;
|
||||
virtual WifiStatus status() const = 0;
|
||||
virtual void disconnect() = 0;
|
||||
virtual Status forget() = 0;
|
||||
};
|
||||
|
||||
struct BleDevice {
|
||||
std::string name;
|
||||
std::string address;
|
||||
int32_t rssi;
|
||||
};
|
||||
|
||||
class BleProvider {
|
||||
public:
|
||||
virtual ~BleProvider() = default;
|
||||
virtual Status init(const std::string* name) = 0;
|
||||
virtual void deinit() = 0;
|
||||
virtual Status scan(int32_t durationMs, std::vector<BleDevice>& devices) = 0;
|
||||
virtual Status connect(const std::string& address) = 0;
|
||||
virtual void disconnect() = 0;
|
||||
virtual bool isConnected() const = 0;
|
||||
virtual Status read(const std::string& service, const std::string& characteristic, std::string& value) = 0;
|
||||
virtual Status write(const std::string& service, const std::string& characteristic, const std::string& value) = 0;
|
||||
virtual Status startAdvertising(const std::string* name) = 0;
|
||||
virtual void stopAdvertising() = 0;
|
||||
};
|
||||
|
||||
enum class TouchPhase { Down, Move, Up };
|
||||
|
||||
class TouchProvider {
|
||||
public:
|
||||
virtual ~TouchProvider() = default;
|
||||
virtual bool touch(int32_t& x, int32_t& y) const = 0;
|
||||
virtual bool rawTouch(int32_t& x, int32_t& y) const = 0;
|
||||
virtual bool isTouched() const = 0;
|
||||
virtual Status setCalibration(int32_t x0, int32_t y0, int32_t x1, int32_t y1) = 0;
|
||||
};
|
||||
|
||||
class ButtonsProvider {
|
||||
public:
|
||||
virtual ~ButtonsProvider() = default;
|
||||
// The roles this device maps physical buttons onto, drawn from the Button union. Hardware
|
||||
// variety lives in the mapping; the vocabulary stays closed so an app stays portable.
|
||||
virtual std::vector<std::string> buttons() const = 0;
|
||||
virtual bool isAnyPressed() const = 0;
|
||||
virtual bool isPressed(const std::string& button) const = 0;
|
||||
virtual bool wasPressed(const std::string& button) const = 0;
|
||||
virtual bool wasReleased(const std::string& button) const = 0;
|
||||
};
|
||||
|
||||
} // namespace esp32lua
|
||||
@@ -0,0 +1,162 @@
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <lua/layout.h>
|
||||
#include <lua/providers.h>
|
||||
|
||||
struct lua_State;
|
||||
|
||||
namespace esp32lua {
|
||||
|
||||
// The version sys.getAPIVersion() reports: this contract, not the firmware's build.
|
||||
constexpr int32_t API_VERSION = 1;
|
||||
|
||||
// Where the runtime looks for apps, their data, and shared modules.
|
||||
struct Paths {
|
||||
std::string apps = "/.lua/apps";
|
||||
std::string data = "/.lua/data";
|
||||
std::string lib = "/.lua/lib";
|
||||
// Where sys.back() lands once history is empty. It is an app like any other.
|
||||
std::string home = "Home";
|
||||
};
|
||||
|
||||
// Firmware supplies every core provider; a null feature provider is how sys.hasFeature()
|
||||
// answers false, and its namespace additions are simply never registered.
|
||||
struct Providers {
|
||||
LogProvider* log = nullptr;
|
||||
SettingsProvider* settings = nullptr;
|
||||
SysProvider* sys = nullptr;
|
||||
FsProvider* fs = nullptr;
|
||||
GuiProvider* gui = nullptr;
|
||||
HttpProvider* http = nullptr;
|
||||
TimerProvider* timer = nullptr;
|
||||
WifiProvider* wifi = nullptr;
|
||||
BleProvider* ble = nullptr;
|
||||
|
||||
TouchProvider* touch = nullptr;
|
||||
ButtonsProvider* buttons = nullptr;
|
||||
};
|
||||
|
||||
class Runtime {
|
||||
public:
|
||||
explicit Runtime(const Providers& providers, const Paths& paths = Paths());
|
||||
~Runtime();
|
||||
|
||||
Runtime(const Runtime&) = delete;
|
||||
Runtime& operator=(const Runtime&) = delete;
|
||||
|
||||
// Fails when a core provider is missing, leaving no Lua state behind.
|
||||
bool open();
|
||||
void close();
|
||||
lua_State* state() const { return state_; }
|
||||
|
||||
// Replaces the running app with a fresh lua_State, loads <apps>/<path>/main.lua, and calls
|
||||
// init(arg). A failure leaves no app running rather than a half-built one.
|
||||
bool startApp(const std::string& path, const std::string& arg = std::string());
|
||||
bool hasApp() const { return !appPath_.empty(); }
|
||||
// The app-relative route, its immutable first component, and the title the app chose.
|
||||
const std::string& appPath() const { return appPath_; }
|
||||
std::string appId() const;
|
||||
std::string appDataPath() const;
|
||||
const std::string& appTitle() const { return appTitle_; }
|
||||
void setAppTitle(const std::string& title) { appTitle_ = title; }
|
||||
bool hasFeature(const std::string& feature) const;
|
||||
|
||||
// sys.launch/replace/back record intent and return; swapping the lua_State inside a callback
|
||||
// would free the VM that is still executing. The firmware applies it between batches.
|
||||
void requestLaunch(const std::string& path, const std::string& arg, bool replace);
|
||||
void requestBack();
|
||||
bool hasPendingNavigation() const { return pending_.kind != Pending::None; }
|
||||
// Loads whatever was requested. False means the app failed to start or history ran out at the
|
||||
// launcher, in which case no app is running.
|
||||
bool applyPendingNavigation();
|
||||
|
||||
LogProvider& log() const { return *providers_.log; }
|
||||
SettingsProvider& settings() const { return *providers_.settings; }
|
||||
SysProvider& sys() const { return *providers_.sys; }
|
||||
FsProvider& fs() const { return *providers_.fs; }
|
||||
GuiProvider& gui() const { return *providers_.gui; }
|
||||
HttpProvider& http() const { return *providers_.http; }
|
||||
TimerProvider& timer() const { return *providers_.timer; }
|
||||
WifiProvider& wifi() const { return *providers_.wifi; }
|
||||
BleProvider& ble() const { return *providers_.ble; }
|
||||
TouchProvider& touch() const { return *providers_.touch; }
|
||||
ButtonsProvider& buttons() const { return *providers_.buttons; }
|
||||
|
||||
ui::Tree& tree() { return tree_; }
|
||||
|
||||
// Entry points into the app. The firmware decides whether an event reaches the app at all --
|
||||
// jitter, chrome and debouncing are its business -- and the runtime decides what the app sees.
|
||||
// Only a failed init() stops an app; every other callback logs and carries on.
|
||||
bool callInit(const std::string& arg);
|
||||
void callDraw(int32_t deltaMs);
|
||||
// An Up phase also fires the on_touch tap alias, in that order.
|
||||
void callTouch(TouchPhase phase, int32_t x, int32_t y);
|
||||
// A release also fires the on_button tap alias, in that order.
|
||||
void callButton(const std::string& button, bool pressed);
|
||||
|
||||
// Timer identity and callback retention are the runtime's; deadlines are the firmware's.
|
||||
TimerId addTimer(int callbackRef, int32_t intervalMs, bool repeating);
|
||||
bool cancelTimer(TimerId id);
|
||||
// Called on the Lua thread when a scheduled deadline elapses.
|
||||
void callTimer(TimerId id);
|
||||
|
||||
static Runtime* from(lua_State* state);
|
||||
|
||||
private:
|
||||
struct Timer {
|
||||
int callbackRef;
|
||||
bool repeating;
|
||||
};
|
||||
|
||||
struct Route {
|
||||
std::string path;
|
||||
std::string arg;
|
||||
};
|
||||
|
||||
struct Pending {
|
||||
enum Kind { None, Launch, Replace, Back } kind = None;
|
||||
Route route;
|
||||
};
|
||||
|
||||
bool loadScript(const std::string& path);
|
||||
void installLoader(const std::string& appDir);
|
||||
static int searchModule(lua_State* state);
|
||||
static int loadFile(lua_State* state);
|
||||
|
||||
// A batch is one visit to the app, however many callbacks it fans out into: a tap fires
|
||||
// on_touch_up and then the on_touch alias, and a timer can fire inside draw. Committing per
|
||||
// callback would refresh an e-ink panel twice for one visible change, so the display is
|
||||
// committed when the outermost call returns.
|
||||
class Batch {
|
||||
public:
|
||||
explicit Batch(Runtime& runtime);
|
||||
~Batch();
|
||||
|
||||
private:
|
||||
Runtime& runtime_;
|
||||
};
|
||||
|
||||
// Pushes the named global, or returns false when the app does not define it.
|
||||
bool beginCall(const char* name);
|
||||
bool finishCall(const char* name, int argc);
|
||||
void cancelAllTimers();
|
||||
|
||||
Providers providers_;
|
||||
lua_State* state_ = nullptr;
|
||||
ui::Tree tree_;
|
||||
std::map<TimerId, Timer> timers_;
|
||||
TimerId nextTimerId_ = 1;
|
||||
int batchDepth_ = 0;
|
||||
|
||||
Paths paths_;
|
||||
std::string appPath_;
|
||||
std::string appTitle_;
|
||||
std::vector<Route> history_;
|
||||
Pending pending_;
|
||||
};
|
||||
|
||||
} // namespace esp32lua
|
||||
Reference in New Issue
Block a user