feat(ui)!: move the widget tree into C++ #1

Merged
evan merged 1 commits from feat/cpp-node-tree into main 2026-08-02 23:19:30 +00:00
21 changed files with 2112 additions and 795 deletions
+55 -8
View File
@@ -20,9 +20,9 @@ Accessors are `getName` / `setName` / `isName`. Bare names are actions (`gui.fil
`wifi.scan`) or pure conversions (`gui.color`, `http.urlencode`). A missing getter is fine;
an accessor without a prefix is not.
Namespaces have one job each: `gui` device primitives and the live frame, `ui` widgets and
palette, `settings` persisted preferences, `sys` process and runtime, plus `wifi`/`http`/
`fs`/`input`/`log`.
Namespaces have one job each: `gui` device primitives and the live frame, `node` the widget
tree, `ui` widgets and palette, `settings` persisted preferences, `sys` process and runtime,
plus `wifi`/`http`/`fs`/`input`/`log`.
**A setter that requires a follow-up call is a bug in the setter.** `settings.setTimezone()`
applies the TZ itself; `settings.setRotation()` applies the frame and re-clips. The one
@@ -37,6 +37,43 @@ Settings live in C++ (`src/settings.h`) because the firmware reads rotation and
before any `lua_State` exists, and calibration again on every touch. Lua reaches them through
bindings so there is one writer.
## The Widget Tree
The tree lives in `src/ui/layout.h`, not in Lua. A node is a **16 byte struct** in a flat
arena; the same tree as Lua tables cost roughly forty times that, and a long list could not
coexist with WiFi's buffers. `sdcard/lib/ui.lua` is a wrapper: `ui.button{...}` returns an
integer handle, so a node carries nothing an app puts on it. Anything an app used to hang on
a node goes in a Lua table keyed by id, which is what `on_press` itself does.
The split is by lifetime, and it is the whole design. `Node` holds what hit testing and
repainting need forever. `Spec` holds what only `measure`/`place` read -- requested size,
pad, gap, alignment -- and is dropped by `node.dropScratch()` when layout ends. **Re-layout
rebuilds from Lua** (~8 ms, which nobody notices on a rotate) rather than retaining ~20 bytes
a node against it. `measure` writes the measured size into `w`/`h` and `place` overwrites the
same slots, because the two are never needed at once.
Style is sparse and inherited: a role unset on a node is answered by the nearest ancestor
that sets it, so a node naming no colours costs zero bytes. That is what keeps `Node` at 16.
Applying a palette is one `node.setStyle()` on a subtree root, which is how a dimmed region
and the lit dialog above it are one call each.
**What is behind a node is derived, never set.** `bg` is the background a node offers its
children to draw text on; the surface an anti-aliased corner blends into is the fill of the
nearest ancestor that actually paints one, or the panel. A dialog layer paints nothing, so
its card blends into the dimmed content two levels up rather than into the lit palette the
layer hands down — make it a style role and the scrim stops at the rounded corners.
**A screen is built from scratch.** The first node created after a layout resets the tree,
automatically -- an explicit reset per build entry point is a chance to forget one and grow
the arena a screen at a time. Handles from the previous screen are dead; update a live one
with `ui.setText(id, text)`.
Layout is `-Wall -Wextra` C++ free of Arduino headers, so `test/ui_layout_test.cpp` runs it
on the host through `make test-cpp`. Adding a **primitive** (a paint routine, a layout mode)
means C++ and a reflash; adding **composition** (`ui.confirm`, a new card) is still Lua on
the SD card. Custom painting is the seam between them: a `custom` node paints itself through
the `gui` bindings.
## Apps
One `lua_State` per app, closed on exit, which is why the heap returns to the same shape
@@ -46,11 +83,15 @@ arguments, never Lua states. Apps receive the string as `init(arg)` -- states sh
memory, so a string is the whole handoff. `sys.setAppName()` retitles the bar for a screen
within an app.
`sdcard/lib/keyboard.lua` deliberately paints all keys in one node; ordinary `ui.button`
keys add dozens of tables and exhaust heap while WiFi is active. Custom painters pair
`paint_part(node, id)` with `node:invalidatePart(id)` when a self-contained region can be
repainted without clearing its parent. For pressed regions, return the ID from `on_down` and
accept that snapshot in `invalidate_press(node, id)` so overlapping 80 ms holds cannot leave stale pixels.
`sdcard/lib/keyboard.lua` paints all keys in one `ui.custom` node; a node per key would add
dozens of nodes and their styles while WiFi already holds buffers. Its geometry (`keyAt`,
`eachKey`) takes an explicit rect and no node, which is why `test/keyboard.lua` can assert
what a tap enters on the host.
A repainted node clears its own box first, and a custom painter is no exception -- the
number page is narrower than the letter page, and the letter page's outer keys survive
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.
## Touch and Drag
@@ -88,3 +129,9 @@ Full instructions in `.pi/skills/test-e32r40t-firmware/SKILL.md`. Two things tha
Pure Lua logic belongs in `test/*.lua` against `test/fake_device.lua`, which is the single
definition of the binding surface for host tests. Renaming a binding means editing that file.
Reserve the emulator for the panel, touch and SD.
`fake_device` fakes the tree's **structure and none of its geometry**, because geometry is
asserted in `test/ui_layout_test.cpp` against the same C++ the panel runs. So a test presses
a control by what it says -- `device.tap("Rotation")`, `device.labelled(prefix)` -- never by
a coordinate. `device.press(id, x, y)` is for widgets with no label to aim at, like the
keyboard, whose own painter decides what a point hit.
+8 -3
View File
@@ -5,7 +5,7 @@ LUA ?= lua
CXX ?= c++
CXXFLAGS ?= -std=c++11 -Wall -Wextra
BUILD_DIR := .pio/build/esp32-32e
LUA_TESTS := test/ui_layout.lua test/ui_theme.lua test/keyboard.lua test/settings_calibration.lua test/statusbar_dirty.lua test/settings_busy.lua
LUA_TESTS := test/ui_theme.lua test/keyboard.lua test/settings_calibration.lua test/statusbar_dirty.lua test/settings_busy.lua
.PHONY: test test-lua test-cpp test-stubs stubs build upload monitor clean
@@ -24,13 +24,18 @@ test-lua:
@$(LUA) -e 'assert(_VERSION == "Lua 5.4", "tests need Lua 5.4 to match the firmware, got " .. _VERSION)'
@for t in $(LUA_TESTS); do printf '%-32s ' "$$t"; $(LUA) "$$t" || exit 1; done
test-cpp: $(BUILD_DIR)/round_rect_test
test-cpp: $(BUILD_DIR)/round_rect_test $(BUILD_DIR)/ui_layout_test
@printf '%-32s ' test/round_rect_test.cpp; $(BUILD_DIR)/round_rect_test
@printf '%-32s ' test/ui_layout_test.cpp; $(BUILD_DIR)/ui_layout_test
$(BUILD_DIR)/round_rect_test: test/round_rect_test.cpp src/gfx/round_rect.h
@mkdir -p $(@D)
@$(CXX) $(CXXFLAGS) $< -o $@
$(BUILD_DIR)/ui_layout_test: test/ui_layout_test.cpp src/ui/layout.h
@mkdir -p $(@D)
@$(CXX) $(CXXFLAGS) $< -o $@
build:
pio run
@@ -42,4 +47,4 @@ monitor:
clean:
pio run -t clean
rm -f $(BUILD_DIR)/round_rect_test
rm -f $(BUILD_DIR)/round_rect_test $(BUILD_DIR)/ui_layout_test
+1 -1
View File
@@ -4,7 +4,7 @@ local keyboard = require("keyboard")
local screen, valueLabel
local function show(prefix, value)
valueLabel:setText(value == "" and "type something" or prefix .. value)
ui.setText(valueLabel, value == "" and "type something" or prefix .. value)
end
function init()
+14 -9
View File
@@ -108,8 +108,8 @@ local function cycleTimezone()
if zone.tz == current then next_index = index % #zones + 1 end
end
local ok = settings.setTimezone(zones[next_index].tz)
timezoneCard.valueLabel:setText(ok and zoneLabel() or "save failed")
timezoneCard:invalidate()
ui.setText(timezoneValue, ok and zoneLabel() or "save failed")
ui.invalidate(timezoneCard)
end
local function wifiValue(status)
@@ -131,7 +131,7 @@ local function card(side, title, value, on_press)
spec[#spec + 1] = valueLabel
end
local button = ui.button(spec)
if title == "Timezone" then button.valueLabel = valueLabel end
if title == "Timezone" then timezoneValue = valueLabel end
return button
end
@@ -236,8 +236,12 @@ local function chooseNetwork(network)
busy("opening keyboard...")
end
local function selectNetwork(button)
chooseNetwork(button.network)
-- The network a card stands for, keyed by node id: a node is sixteen bytes in the
-- firmware and carries nothing an app puts on it.
local networkOf = {}
local function selectNetwork(id)
chooseNetwork(networkOf[id])
end
function buildNetworks(networks)
@@ -257,11 +261,12 @@ function buildNetworks(networks)
for i = 1, math.min(#list, 6) do
local network = list[i]
local lock = network.secure and " *" or ""
items[#items + 1] = ui.button{
local card = ui.button{
label = network.ssid .. lock .. " " .. network.rssi,
network = network,
on_press = selectNetwork,
}
networkOf[card] = network
items[#items + 1] = card
end
log.info("wifi scan found " .. #list .. " networks")
if #list == 0 then items[#items + 1] = ui.text("no networks found", {color = ui.theme.muted}) end
@@ -272,8 +277,8 @@ end
function updatePassword(value)
password = value
passwordLabel:setText("password: " .. password)
passwordRow:invalidate()
ui.setText(passwordLabel, "password: " .. password)
ui.invalidate(passwordRow)
end
function buildKeyboard()
+100 -65
View File
@@ -11,6 +11,10 @@ local PAGES = {
symbols = {"[]{}#%^*+=", "_\\|~<>$&@", ".,?!'"},
}
-- Keyed by node id, because the node itself is sixteen bytes in the firmware and holds
-- nothing a keyboard cares about.
local state = {}
local function chars(page, row)
return PAGES[page][row]
end
@@ -19,25 +23,28 @@ local function rowWidth(count)
return count * KEY_W + (count - 1) * KEY_GAP
end
local function centeredX(node, width)
return node.rect.x + (node.rect.w - width) // 2
local function centeredX(rect, width)
return rect.x + (rect.w - width) // 2
end
local function thirdRow(node)
local value = chars(node.page, 3)
local function thirdRow(page, rect)
local value = chars(page, 3)
local width = SIDE_W * 2 + KEY_GAP * 2 + rowWidth(#value)
return value, centeredX(node, width)
return value, centeredX(rect, width)
end
local function keyAt(node, x, y)
local localY = y - node.rect.y
-- Pure geometry: which key covers a point, given a page and the rectangle the board was
-- placed in. Kept free of the node tree so the host tests can exercise the maths that
-- actually decides what a tap enters.
function M.keyAt(page, rect, x, y)
local localY = y - rect.y
if localY < 0 then return end
local row = localY // (KEY_H + ROW_GAP) + 1
if row > 4 or localY % (KEY_H + ROW_GAP) >= KEY_H then return end
if row <= 2 then
local value = chars(node.page, row)
local localX = x - centeredX(node, rowWidth(#value))
local value = chars(page, row)
local localX = x - centeredX(rect, rowWidth(#value))
if localX < 0 then return end
local column = localX // (KEY_W + KEY_GAP) + 1
if column <= #value and localX % (KEY_W + KEY_GAP) < KEY_W then
@@ -47,11 +54,11 @@ local function keyAt(node, x, y)
end
if row == 3 then
local value, start = thirdRow(node)
local value, start = thirdRow(page, rect)
local localX = x - start
if localX < 0 then return end
if localX < SIDE_W then
return 90, (node.page == "lower" or node.page == "upper") and "shift" or "symbols"
return 90, (page == "lower" or page == "upper") and "shift" or "symbols"
end
localX = localX - SIDE_W - KEY_GAP
local width = rowWidth(#value)
@@ -68,7 +75,7 @@ local function keyAt(node, x, y)
end
local width = MODE_W + SPACE_W + OK_W + KEY_GAP * 2
local localX = x - centeredX(node, width)
local localX = x - centeredX(rect, width)
if localX < 0 then return end
if localX < MODE_W then return 100, "mode" end
localX = localX - MODE_W - KEY_GAP
@@ -90,108 +97,136 @@ local function drawArrow(x, y, width, color, down)
end
end
local function drawKey(node, id, label, x, y, width)
local pressed = node.pressed and node.activeKey == id
local gradient = pressed and node.press_gradient or node.gradient
local color = pressed and (node.press_color or node.bg) or node.color
gui.roundRect(x, y, width, KEY_H, node.radius, node.bg,
gradient[1], gradient[2], color)
local function drawKey(id, label, x, y, width, pressed)
local theme = ui.theme
local gradient = pressed and theme.face_pressed or theme.face
local color = pressed and theme.accent_fg or theme.fg
gui.roundRect(x, y, width, KEY_H, theme.radius, theme.bg, gradient[1], gradient[2], color)
if label == "shift" then
drawArrow(x, y, width, color, node.page == "upper")
drawArrow(x, y, width, color, state[id].page == "upper")
else
gui.drawText(label, x + (width - gui.getTextWidth(label)) // 2,
y + (KEY_H - gui.getFontHeight()) // 2, color, gradient[2])
end
end
local function paint(node, part)
local function key(id, label, x, y, width)
if not part or part == id then drawKey(node, id, label, x, y, width) end
end
-- Walks every key, handing each one to `visit`. Painting the whole board and repainting a
-- single key are the same traversal, so a key's position is defined in one place. Pure,
-- for the same reason keyAt is.
function M.eachKey(page, rect, visit)
local y = rect.y
local y = node.rect.y
for row = 1, 2 do
local value = chars(node.page, row)
local x = centeredX(node, rowWidth(#value))
local value = chars(page, row)
local x = centeredX(rect, rowWidth(#value))
for column = 1, #value do
key((row - 1) * 10 + column, value:sub(column, column), x, y, KEY_W)
visit((row - 1) * 10 + column, value:sub(column, column), x, y, KEY_W)
x = x + KEY_W + KEY_GAP
end
y = y + KEY_H + ROW_GAP
end
local value, x = thirdRow(node)
key(90, (node.page == "lower" or node.page == "upper") and "shift" or
(node.page == "numbers" and "#+=" or "123"), x, y, SIDE_W)
local value, x = thirdRow(page, rect)
visit(90, (page == "lower" or page == "upper") and "shift" or
(page == "numbers" and "#+=" or "123"), x, y, SIDE_W)
x = x + SIDE_W + KEY_GAP
for column = 1, #value do
key(20 + column, value:sub(column, column), x, y, KEY_W)
visit(20 + column, value:sub(column, column), x, y, KEY_W)
x = x + KEY_W + KEY_GAP
end
key(91, "<-", x, y, SIDE_W)
visit(91, "<-", x, y, SIDE_W)
y = y + KEY_H + ROW_GAP
local width = MODE_W + SPACE_W + OK_W + KEY_GAP * 2
x = centeredX(node, width)
key(100, (node.page == "lower" or node.page == "upper") and "123" or "ABC", x, y, MODE_W)
x = centeredX(rect, width)
visit(100, (page == "lower" or page == "upper") and "123" or "ABC", x, y, MODE_W)
x = x + MODE_W + KEY_GAP
key(101, "space", x, y, SPACE_W)
visit(101, "space", x, y, SPACE_W)
x = x + SPACE_W + KEY_GAP
key(102, "OK", x, y, OK_W)
visit(102, "OK", x, y, OK_W)
end
local function down(node, x, y)
node.activeKey = keyAt(node, x, y)
return node.activeKey
local function rectOf(id)
local x, y, w, h = node.getRect(id)
return {x = x, y = y, w = w, h = h}
end
local function changed(node)
if node.on_change then node.on_change(node.value) end
local function paint(id)
M.eachKey(state[id].page, rectOf(id), function(key, label, x, y, width)
drawKey(id, label, x, y, width, false)
end)
end
local function press(node, x, y)
local id, action, char = keyAt(node, x, y)
if not id or id ~= node.activeKey then return end
-- Repaints one key in place. The keyboard is a single node, so there is no parent to
-- clear and nothing else on screen can have moved.
local function drawOneKey(id, target, pressed)
M.eachKey(state[id].page, rectOf(id), function(key, label, x, y, width)
if key == target then drawKey(id, label, x, y, width, pressed) end
end)
end
local function down(id, x, y)
local key = M.keyAt(state[id].page, rectOf(id), x, y)
state[id].activeKey = key
if key then drawOneKey(id, key, true) end
end
local function unpress(id)
local key = state[id].activeKey
state[id].activeKey = nil
if key then drawOneKey(id, key, false) end
end
local function press(id, x, y)
local st = state[id]
local key, action, char = M.keyAt(st.page, rectOf(id), x, y)
if not key or key ~= st.activeKey then return end
local function changed()
if st.on_change then st.on_change(st.value) end
end
if action == "char" then
if #node.value < node.max_length then node.value = node.value .. char; changed(node) end
if #st.value < st.max_length then st.value = st.value .. char; changed() end
elseif action == "shift" then
node.page = node.page == "lower" and "upper" or "lower"
node:invalidate()
st.page = st.page == "lower" and "upper" or "lower"
node.invalidate(id)
elseif action == "symbols" then
node.page = node.page == "numbers" and "symbols" or "numbers"
node:invalidate()
st.page = st.page == "numbers" and "symbols" or "numbers"
node.invalidate(id)
elseif action == "mode" then
node.page = (node.page == "lower" or node.page == "upper") and "numbers" or "lower"
node:invalidate()
st.page = (st.page == "lower" or st.page == "upper") and "numbers" or "lower"
node.invalidate(id)
elseif action == "backspace" then
node.value = node.value:sub(1, -2)
changed(node)
st.value = st.value:sub(1, -2)
changed()
elseif action == "space" then
if #node.value < node.max_length then node.value = node.value .. " "; changed(node) end
elseif action == "submit" and node.on_submit then
node.on_submit(node.value)
if #st.value < st.max_length then st.value = st.value .. " "; changed() end
elseif action == "submit" and st.on_submit then
st.on_submit(st.value)
end
end
-- One custom-painted node keeps a full keyboard from retaining dozens of component tables.
-- One custom-painted node keeps a full keyboard off the heap: ordinary ui.button keys
-- would add dozens of nodes and their styles while wifi is already holding buffers.
function M.new(spec)
spec = spec or {}
return ui.box{
local id = ui.custom{
h = 4 * KEY_H + 3 * ROW_GAP,
paint = paint,
on_down = down,
on_press = press,
on_unpress = unpress,
press_style = false, -- a key highlights itself; the node never does
}
state[id] = {
value = spec.value or "",
max_length = spec.max_length or 64,
page = "lower",
on_change = spec.on_change,
on_submit = spec.on_submit,
paint = paint,
paint_part = paint,
invalidate_press = function(node, part)
if part then node:invalidatePart(part) end
end,
on_down = down,
on_press = press,
}
return id
end
return M
+152 -319
View File
@@ -1,6 +1,10 @@
-- Layout borrowed from CSS block flow: nesting plus box model, no cascade.
-- Sizes are pixels (>= 1), a fraction of the parent content box (< 1), "fill" for all
-- of it, or "auto" to size to content.
-- Widgets and palette. The tree itself lives in the firmware: every constructor here
-- returns an integer handle, and the C++ arena holds sixteen bytes where a Lua table
-- held several hundred.
--
-- Layout is borrowed from CSS block flow: nesting plus box model, no cascade. Sizes are
-- pixels (>= 1), a fraction of the parent content box (< 1), "fill" for all of it, or
-- "auto" to size to content.
local ui = {}
@@ -107,277 +111,98 @@ end
ui.reloadTheme()
local function resolve(value, span, axis)
if value == nil or value == "auto" then return nil end
-- "fill" exists because a fraction cannot say 1.0: any number >= 1 is a pixel count,
-- so w = 1.0 asks for a single pixel. Covering a parent needs a word, not a number.
if value == "fill" then
if span == nil then
error("fill " .. axis .. " inside an auto-sized parent", 3)
end
return span
end
if value < 1 then
if span == nil then
error("fractional " .. axis .. " inside an auto-sized parent", 3)
end
return math.floor(value * span)
end
return math.floor(value)
-- Handlers -----------------------------------------------------------------
-- Callbacks stay in Lua, keyed by node id. Ids are handed out densely from zero, so
-- these are array parts rather than hashes, which is cheaper than a registry reference
-- and leaves nothing to release.
local press, down, unpress, painters, flat = {}, {}, {}, {}, {}
-- Which nodes were given a whole palette, and which one. A root that already chose the
-- dim palette must not be reseeded with the lit one, and the panel is cleared to whatever
-- the root resolved to rather than to the theme's own background.
local paletted = {}
-- Set once a tree has been laid out, so the next node built starts a new screen. Apps
-- rebuild rather than mutate, and making that automatic is what keeps a forgotten reset
-- from quietly growing the arena one screen at a time.
local laidOut = false
local function forget()
press, down, unpress, painters, flat = {}, {}, {}, {}, {}
paletted = {}
end
local function padding(spec)
local p = spec.pad or 0
if type(p) == "number" then return {t = p, r = p, b = p, l = p} end
return {t = p.t or 0, r = p.r or 0, b = p.b or 0, l = p.l or 0}
end
-- The palette set on the root reaches every descendant, so styling is one place.
local INHERITED = {"color", "bg", "size", "button_bg", "press_bg", "press_color", "radius",
"gradient", "press_gradient", "dimmed"}
-- The root seeds the tree, so an app that names no colors is themed by inheritance.
local ROOT_STYLE = {
color = "fg", bg = "bg", press_bg = "accent", press_color = "accent_fg",
gradient = "face", press_gradient = "face_pressed", radius = "radius",
}
local function seedFrom(node, palette, style)
for key, role in pairs(ROOT_STYLE) do
if node[key] == nil then node[key] = (style and style[key]) or palette[role] end
end
end
local function inherit(child, parent)
-- Crossing into or out of a dimmed region restyles from the matching palette instead
-- of inheriting its neighbour's, which is how a dialog stays lit over dimmed content.
if child.dimmed ~= nil and child.dimmed ~= parent.dimmed then
seedFrom(child, child.dimmed and ui.theme.dim or ui.theme)
end
for _, key in ipairs(INHERITED) do
if child[key] == nil then child[key] = parent[key] end
end
-- What this node sits on, which is not the same as what it fills. Anti-aliased edges
-- blend into the surface, so a rounded card needs the color behind it and not its own.
if child.surface == nil then child.surface = parent.bg or parent.surface end
end
local function contains(rect, x, y)
return x >= rect.x - SLOP and x < rect.x + rect.w + SLOP
and y >= rect.y - SLOP and y < rect.y + rect.h + SLOP
end
-- Every node's paint routine is looked up here, so one dispatcher serves the whole tree.
node.setPainter(function(id, x, y, w, h)
local paint = painters[id]
if paint then paint(id, x, y, w, h) end
end)
-- Components ---------------------------------------------------------------
local Component = {}
Component.__index = Component
local STYLE_KEYS = {"color", "border", "face", "face_pressed", "press_color",
"radius", "size", "text_align"}
function Component:measure(available)
local pad = self.pad_
local w = resolve(self.w, available.w, "width")
local h = resolve(self.h, available.h, "height")
local inner = {
w = w and w - pad.l - pad.r or (available.w and available.w - pad.l - pad.r),
h = h and h - pad.t - pad.b or nil,
}
local main, cross = 0, 0
for index, child in ipairs(self.children) do
inherit(child, self)
local cw, ch = child:measure(inner)
if self.row then
main = main + cw + (index > 1 and self.gap or 0)
cross = math.max(cross, ch)
else
main = main + ch + (index > 1 and self.gap or 0)
cross = math.max(cross, cw)
end
end
if self.row then
self.mw = w or main + pad.l + pad.r
self.mh = h or cross + pad.t + pad.b
else
self.mw = w or cross + pad.l + pad.r
self.mh = h or main + pad.t + pad.b
end
return self.mw, self.mh
-- Applies a whole palette to one node. Descendants inherit it by walking up, so a
-- dimmed region and the lit dialog above it are each one call, not a tree walk.
local function applyPalette(id, theme)
paletted[id] = theme
node.setStyle(id, {
color = theme.fg, bg = theme.bg,
face = theme.face, face_pressed = theme.face_pressed,
press_color = theme.accent_fg, radius = theme.radius,
})
end
function Component:place(rect)
self.rect = rect
self.dirty = true
local pad = self.pad_
local content = {
x = rect.x + pad.l,
y = rect.y + pad.t,
w = rect.w - pad.l - pad.r,
h = rect.h - pad.t - pad.b,
}
-- Main-axis distribution, CSS justify-content minus the modes nothing here asks for.
-- Absolutely placed children take no part in the flow, so they are excluded.
local flowing, used = 0, 0
for _, child in ipairs(self.children) do
if not child.at then
flowing = flowing + 1
used = used + (self.row and child.mw or child.mh)
end
local function applyStyle(id, spec)
if spec.dimmed ~= nil then
applyPalette(id, spec.dimmed and ui.theme.dim or ui.theme)
end
used = used + math.max(0, flowing - 1) * self.gap
local free = math.max(0, (self.row and content.w or content.h) - used)
local offset, spread = 0, 0
if self.justify == "end" then offset = free
elseif self.justify == "center" then offset = math.floor(free / 2)
elseif self.justify == "between" and flowing > 1 then spread = math.floor(free / (flowing - 1))
local style, any = {}, false
for _, key in ipairs(STYLE_KEYS) do
if spec[key] ~= nil then style[key], any = spec[key], true end
end
for _, child in ipairs(self.children) do
local cw, ch = child.mw, child.mh
-- Cross axis fills the parent unless the child asked for a size, like CSS blocks.
if self.row then
if child.h == nil or child.h == "auto" then ch = content.h end
else
if child.w == nil or child.w == "auto" then cw = content.w end
end
local x, y
if child.at then
x = content.x + resolve(child.at.x, content.w, "x")
y = content.y + resolve(child.at.y, content.h, "y")
elseif self.row then
x, y = content.x + offset, content.y
if self.align == "center" then y = y + math.floor((content.h - ch) / 2)
elseif self.align == "end" then y = y + content.h - ch end
offset = offset + cw + self.gap + spread
else
x, y = content.x, content.y + offset
if self.align == "center" then x = x + math.floor((content.w - cw) / 2)
elseif self.align == "end" then x = x + content.w - cw end
offset = offset + ch + self.gap + spread
end
child:place{x = x, y = y, w = cw, h = ch}
-- A box that names a background both paints it and offers it to its children; `false`
-- says to paint nothing, which is how a dialog layer covers without covering up.
if type(spec.bg) == "number" then
style.bg, style.fill, any = spec.bg, spec.bg, true
end
if any then node.setStyle(id, style) end
end
function Component:draw()
if self.dirty then
if self.bg and not self.paintsBackground then
gui.fillRect(self.rect.x, self.rect.y, self.rect.w, self.rect.h, self.bg)
end
if self.paint then self:paint() end
self.dirty = false
self.dirtyParts = nil
for _, child in ipairs(self.children) do child.dirty = true end
elseif self.dirtyParts then
local parts = self.dirtyParts
self.dirtyParts = nil
for part in pairs(parts) do self:paint_part(part) end
end
for _, child in ipairs(self.children) do child:draw() end
end
-- Deepest interactive component wins, so a tappable child beats its tappable parent.
function Component:hit(x, y)
if not self.rect or not contains(self.rect, x, y) then return nil end
for index = #self.children, 1, -1 do
local found = self.children[index]:hit(x, y)
if found then return found end
end
-- A capturing component swallows the taps its children missed, so what it covers
-- cannot be tapped through. Without it the hit walk falls back to earlier siblings,
-- which is how a dialog would let you press the button underneath it.
if self.capture then return self end
return self.on_press and self or nil
end
function Component:invalidate()
self.dirty = true
end
function Component:invalidatePart(part)
if part == nil or not self.paint_part then return self:invalidate() end
if self.dirty then return end
self.dirtyParts = self.dirtyParts or {}
self.dirtyParts[part] = true
end
local function component(spec)
-- A bordered box is drawn as a rounded rect over its own background fill. The fill is
-- square and the border is not, but both are the same color as whatever sits behind a
-- box on this panel, so the corners have nothing to give away.
-- A bordered box paints its own background as a rounded rect, so the square fill in
-- Component:draw is suppressed. Filling first would leave square corners outside the
-- border, which is invisible against a matching surface and obvious against any other.
if spec.border then
spec.paintsBackground = true
spec.paint = function(self)
local r = self.rect
gui.roundRect(r.x, r.y, r.w, r.h, self.radius or ui.theme.radius, self.surface,
self.bg, self.bg, self.border)
end
end
spec.children = spec.children or {}
local function build(spec, kind)
if laidOut then ui.reset() end
local children = {}
for index, child in ipairs(spec) do
spec.children[index] = child
children[index] = child
spec[index] = nil
end
spec.gap = spec.gap or 0
spec.align = spec.align or "start"
spec.pad_ = padding(spec)
return setmetatable(spec, Component)
spec.type = kind
spec.interactive = spec.on_press ~= nil or spec.on_down ~= nil
local id = node.create(nil, spec)
for _, child in ipairs(children) do node.attach(id, child) end
applyStyle(id, spec)
if spec.on_press then press[id] = spec.on_press end
if spec.on_down then down[id] = spec.on_down end
if spec.on_unpress then unpress[id] = spec.on_unpress end
if spec.paint then painters[id] = spec.paint end
if spec.press_style == false then flat[id] = true end
return id
end
ui.box = component
function ui.box(spec)
return build(spec, "box")
end
function ui.spacer(spec)
return component{w = spec.w, h = spec.h}
end
-- Text size is panel state, not node state, so every measure and paint sets it: a
-- neighbour at another size would otherwise decide this node's metrics.
local function measureText(self, available)
gui.setTextSize(self.size or 1)
self.mw = resolve(self.w, available.w, "width") or gui.getTextWidth(self.label)
self.mh = resolve(self.h, available.h, "height") or gui.getFontHeight()
return self.mw, self.mh
end
local function paintText(self)
gui.setTextSize(self.size or 1)
local x = self.rect.x
if self.text_align == "center" then x = x + (self.rect.w - gui.getTextWidth(self.label)) // 2
elseif self.text_align == "end" then x = x + self.rect.w - gui.getTextWidth(self.label) end
gui.drawText(self.label, x, self.rect.y, self.color, self.bg)
end
local function setText(self, text)
if text == self.label then return end
self.label = text
self:invalidate()
return build({w = spec.w, h = spec.h}, "box")
end
function ui.text(label, spec)
spec = spec or {}
spec.label = label
local node = component(spec)
node.measure = measureText
node.paint = paintText
node.setText = setText
return node
end
-- Geometry for a grid of square cards: a wide frame takes another column, and the side
-- shrinks until every row fits, because there is no scrolling and a card below the fold
-- cannot be tapped. `reserve` is height the caller needs for anything under the grid.
-- ponytail: enough cards make them unusably small; that is the point to add scrolling.
function ui.cardSide(count, pad, gap, reserve)
local cols = gui.getWidth() >= gui.getHeight() and 4 or 3
local rows = math.ceil(count / cols)
local byWidth = (gui.getWidth() - 2 * pad - (cols - 1) * gap) // cols
local byHeight = (gui.getHeight() - 2 * pad - (reserve or 0) - (rows - 1) * gap) // rows
return math.min(byWidth, byHeight), cols
return build(spec, "text")
end
-- A text node sized to its own glyphs, so a centering parent has something to center: a
@@ -394,47 +219,45 @@ function ui.label(text, spec)
return ui.text(text, spec)
end
local function paintButton(self)
local r = self.rect
local gradient = self.pressed and self.press_gradient or (not self.pressed and self.gradient)
local top, bottom
if gradient then
top, bottom = gradient[1], gradient[2]
else
top = self.pressed and (self.press_bg or self.color) or self.button_bg
bottom = top
end
gui.roundRect(r.x, r.y, r.w, r.h, self.radius or ui.theme.radius, self.bg, top, bottom, self.color)
for _, child in ipairs(self.children) do
-- No background for a gradient face: an opaque glyph fill is one flat colour, which
-- only matches the one row of the gradient it was taken from. The face is repainted
-- above on every change, so the children have nothing to erase.
-- `x and nil or y` can never be nil, so this stays an if.
if gradient then child.bg = nil else child.bg = bottom or self.bg end
child.color = self.pressed and (self.press_color or self.bg) or self.color
child.dirty = true
end
end
local function setButtonText(self, text)
local label = self.children[1]
if text == label.label then return end
label:setText(text)
self:invalidate()
end
function ui.button(spec)
spec.pad = spec.pad or 8
spec.align = spec.align or "center"
local hasLabel = spec.label ~= nil
if hasLabel then
spec.children = {ui.text(spec.label)}
spec.label = nil
end
local node = component(spec)
node.paint = paintButton
if hasLabel then node.setText = setButtonText end
return node
local label, size = spec.label, spec.size
spec.label = nil
local id = build(spec, "button")
-- The label is created after its button, so it needs no adoption.
if label then node.create(id, {type = "text", label = label, size = size}) end
return id
end
-- A node that paints itself through the gui bindings. One node instead of a table per
-- part is what keeps a full keyboard off the heap while wifi is up.
function ui.custom(spec)
return build(spec, "custom")
end
-- Replaces a node's text. The node keeps the box it was placed with, so this is for
-- values that fit the space already reserved for them.
function ui.setText(id, text)
if node.getLabel(id) == text then return end
node.setLabel(id, text)
node.invalidate(id)
end
function ui.invalidate(id)
node.invalidate(id)
end
-- Geometry for a grid of square cards: a wide frame takes another column, and the side
-- shrinks until every row fits, because there is no scrolling and a card below the fold
-- cannot be tapped. `reserve` is height the caller needs for anything under the grid.
-- ponytail: enough cards make them unusably small; that is the point to add scrolling.
function ui.cardSide(count, pad, gap, reserve)
local cols = gui.getWidth() >= gui.getHeight() and 4 or 3
local rows = math.ceil(count / cols)
local byWidth = (gui.getWidth() - 2 * pad - (cols - 1) * gap) // cols
local byHeight = (gui.getHeight() - 2 * pad - (reserve or 0) - (rows - 1) * gap) // rows
return math.min(byWidth, byHeight), cols
end
-- A dialog is not a layer the toolkit manages: it is a node an app includes when its
@@ -458,11 +281,11 @@ function ui.confirm(spec)
buttons[#buttons + 1] = ui.button{label = spec.ok or "ok", on_press = spec.on_ok}
card[#card + 1] = ui.box(buttons)
-- bg = false leaves the app's own screen visible around the card; capture stops it
-- being tapped. on_press is deliberately absent, so a stray touch answers nothing.
-- bg is left unset so the app's own screen stays visible around the card; capture stops
-- it being tapped. on_press is optional, so a stray touch answers nothing by default.
return ui.box{
at = {x = 0, y = 0}, w = "fill", h = "fill",
bg = false, capture = true, dimmed = false, -- the card is lit, whatever is behind it
capture = true, dimmed = false, -- the card is lit, whatever is behind it
align = "center", justify = "center",
on_press = spec.on_outside,
ui.box(card),
@@ -474,68 +297,78 @@ end
local Screen = {}
Screen.__index = Screen
-- Starts a new tree. Every screen is built from scratch, which is why the heap returns to
-- the same shape after each one instead of fragmenting.
function ui.reset()
node.reset()
forget()
laidOut = false
end
function ui.screen(root, style)
local screen = setmetatable({root = root, captured = nil, pressedAt = 0}, Screen)
-- The root is placed at the full panel rect, so it must measure that way too. Leaving
-- it auto made its height unknown to its own children, and a child asking for a
-- fraction of the screen failed inside the one component whose size is never in doubt.
if root.w == nil then root.w = "fill" end
if root.h == nil then root.h = "fill" end
seedFrom(root, root.dimmed and ui.theme.dim or ui.theme, style)
node.setSize(root, "fill", "fill")
if not paletted[root] then applyPalette(root, ui.theme) end
if style then node.setStyle(root, style) end
local screen = setmetatable({root = root, captured = nil, pressedAt = 0}, Screen)
screen:relayout()
return screen
end
function Screen:relayout()
local rect = {x = 0, y = 0, w = gui.getWidth(), h = gui.getHeight()}
self.root:measure(rect)
self.root:place(rect)
gui.clear(self.root.bg)
end
local function invalidatePressed(target, part)
if target.invalidate_press then target:invalidate_press(part) else target:invalidate() end
local ok, err = node.layout(self.root, 0, 0, gui.getWidth(), gui.getHeight())
if not ok then error(err, 2) end
node.dropScratch()
laidOut = true
gui.clear((paletted[self.root] or ui.theme).bg)
end
local function finishRelease(screen)
local released = screen.released
if not released then return end
released.target.pressed = false
invalidatePressed(released.target, released.part)
if unpress[released.target] then
unpress[released.target](released.target)
else
node.setPressed(released.target, false)
end
screen.released = nil
end
function Screen:draw()
-- Same-Frame Release - Queue the normal state before drawing so it does not wait for another frame.
-- Same-Frame Release - Queue the normal state before drawing so it does not wait for
-- another frame.
if self.released and sys.getMillis() - self.released.pressedAt >= PRESS_MS then
finishRelease(self)
end
self.root:draw()
node.draw(self.root)
end
function Screen:down(x, y)
-- Overlapping Presses - A second tap can begin during the first tap's 80 ms hold. Finish the old visual before changing a custom component's active sub-part.
-- Overlapping Presses - A second tap can begin during the first tap's 80 ms hold.
-- Finish the old visual before a custom component changes what it is highlighting.
finishRelease(self)
local target = self.root:hit(x, y)
local target = node.hit(self.root, x, y)
if not target then return end
self.captured = target
self.pressedAt = sys.getMillis()
self.capturedPart = target.on_down and target.on_down(target, x, y) or nil
if target.press_style ~= false then
target.pressed = true
invalidatePressed(target, self.capturedPart)
end
if down[target] then down[target](target, x, y) end
if not flat[target] then node.setPressed(target, true) end
end
function Screen:up(x, y)
local target, part = self.captured, self.capturedPart
self.captured, self.capturedPart = nil, nil
local target = self.captured
self.captured = nil
if not target then return end
if target.press_style ~= false then
self.released = {target = target, part = part, pressedAt = self.pressedAt}
if not flat[target] or unpress[target] then
self.released = {target = target, pressedAt = self.pressedAt}
end
local inside = contains(target.rect, x, y)
if inside and target.on_press then target.on_press(target, x, y) end
local rx, ry, rw, rh = node.getRect(target)
local inside = x >= rx - SLOP and x < rx + rw + SLOP
and y >= ry - SLOP and y < ry + rh + SLOP
if inside and press[target] then press[target](target, x, y) end
end
return ui
+1
View File
@@ -19,6 +19,7 @@ LuaApp* app(lua_State* L);
void bindApp(lua_State* L, LuaApp* owner);
void registerGui(lua_State* L);
void registerNode(lua_State* L);
void registerSys(lua_State* L);
void registerSettings(lua_State* L);
void registerInput(lua_State* L);
+7 -37
View File
@@ -2,7 +2,7 @@
#include <TFT_eSPI.h>
#include "../../gfx/round_rect.h"
#include "../../ui/paint.h"
#include "../bindings.h"
#include "../lua_app.h"
@@ -47,47 +47,17 @@ static int l_gui_drawLine(lua_State* L) {
return 0;
}
// One primitive draws the whole surface: fill (solid or vertical gradient) and border
// derive from the same distance field, so they cannot disagree at the corners the way
// two separate rounded-rect algorithms did. The panel has no alpha, so edge pixels are
// blended against `bg`, the colour of the surface underneath.
// The same primitive the node tree paints buttons and cards with, so a Lua custom
// painter and a built-in widget cannot render a different rounded rect.
static int l_gui_roundRect(lua_State* L) {
int x = luaL_checkinteger(L, 1), y = luaL_checkinteger(L, 2);
int w = luaL_checkinteger(L, 3), h = luaL_checkinteger(L, 4);
float radius = luaL_checkinteger(L, 5);
uint16_t bg = luaL_checkinteger(L, 6);
bool hasFill = !lua_isnoneornil(L, 7);
bool hasBorder = !lua_isnoneornil(L, 9);
uint16_t top = hasFill ? luaL_checkinteger(L, 7) : 0;
uint16_t bottom = lua_isnoneornil(L, 8) ? top : luaL_checkinteger(L, 8);
uint16_t border = hasBorder ? luaL_checkinteger(L, 9) : 0;
if (w <= 0 || h <= 0 || w > LuaApp::MAX_SPAN) return 0;
radius = constrain(radius, 0.0f, min(w, h) / 2.0f);
float halfWidth = w * 0.5f, halfHeight = h * 0.5f;
static uint16_t span[LuaApp::MAX_SPAN];
// pushImage sends the buffer verbatim, but the panel wants each colour big-endian.
bool previousSwap = app(L)->tft.getSwapBytes();
app(L)->tft.setSwapBytes(true);
for (int row = 0; row < h; row++) {
uint16_t fill = hasFill ? gfx::lerp565(top, bottom, row, h - 1) : 0;
float py = row + 0.5f - halfHeight;
for (int column = 0; column < w; column++) {
float distance =
gfx::roundRectDistance(column + 0.5f - halfWidth, py, halfWidth, halfHeight, radius);
float outer = gfx::coverage(distance);
// The border is the ring between the shape and the same shape inset by its width.
float inner = hasBorder ? gfx::coverage(distance + 1.0f) : outer;
uint16_t pixel = bg;
if (hasFill) pixel = gfx::blend565(pixel, fill, inner);
if (hasBorder) pixel = gfx::blend565(pixel, border, outer - inner);
span[column] = pixel;
}
app(L)->tft.pushImage(x, y + row, w, 1, span);
}
app(L)->tft.setSwapBytes(previousSwap);
ui::drawRoundRect(app(L)->tft, luaL_checkinteger(L, 1), luaL_checkinteger(L, 2),
luaL_checkinteger(L, 3), luaL_checkinteger(L, 4), luaL_checkinteger(L, 5),
luaL_checkinteger(L, 6), hasFill, top, bottom, hasBorder,
hasBorder ? luaL_checkinteger(L, 9) : 0);
return 0;
}
+469
View File
@@ -0,0 +1,469 @@
// The retained widget tree. Lua describes a screen once and gets integer handles back;
// the nodes themselves live in a flat C++ arena at 16 bytes each, where the same tree
// as Lua tables cost roughly forty times that.
//
// This namespace owns structure and geometry only. Composition (ui.confirm, ui.label),
// the palette, and press callbacks stay in /lib/ui.lua -- a callback keyed by node id in
// an ordinary Lua table is cheaper than a registry reference and far less bookkeeping.
#include <TFT_eSPI.h>
#include "../../ui/paint.h"
#include "../bindings.h"
#include "../lua_app.h"
// The Lua function that paints CUSTOM nodes, if the toolkit installed one. A single
// registry slot for the whole tree: Lua keys its own painters by node id.
static const char* PAINTER_KEY = "slate32.node.painter";
static ui::Tree& tree(lua_State* L) { return app(L)->tree; }
// Every id crosses from Lua, so every id is checked: a stale handle must raise a Lua
// error, not index past the end of the arena.
static uint16_t checkId(lua_State* L, int index) {
lua_Integer id = luaL_checkinteger(L, index);
luaL_argcheck(L, id >= 0 && static_cast<size_t>(id) < tree(L).nodes.size(), index,
"no such node");
return static_cast<uint16_t>(id);
}
// Pixels (>= 1), a fraction of the parent content box (< 1), "fill" for all of it, or
// "auto" to size to content. Fractions are carried as per-mille so layout stays integral.
static ui::Size decodeSize(lua_State* L, int index) {
if (lua_isnumber(L, index)) {
double value = lua_tonumber(L, index);
return value < 1.0 ? ui::Size::fraction(static_cast<int16_t>(value * 1000.0 + 0.5))
: ui::Size::px(static_cast<int16_t>(value));
}
if (lua_isstring(L, index) && strcmp(lua_tostring(L, index), "fill") == 0) {
return ui::Size::fill();
}
return ui::Size();
}
static ui::Size readSize(lua_State* L, int table, const char* key) {
lua_getfield(L, table, key);
ui::Size size = decodeSize(L, -1);
lua_pop(L, 1);
return size;
}
static int readInt(lua_State* L, int table, const char* key, int fallback = 0) {
lua_getfield(L, table, key);
int value = lua_isnumber(L, -1) ? static_cast<int>(lua_tointeger(L, -1)) : fallback;
lua_pop(L, 1);
return value;
}
static bool readBool(lua_State* L, int table, const char* key) {
lua_getfield(L, table, key);
bool value = lua_toboolean(L, -1);
lua_pop(L, 1);
return value;
}
static ui::Align readAlign(lua_State* L, int table, const char* key) {
lua_getfield(L, table, key);
const char* name = lua_isstring(L, -1) ? lua_tostring(L, -1) : "start";
ui::Align align = ui::START;
if (strcmp(name, "center") == 0) {
align = ui::CENTER;
} else if (strcmp(name, "end") == 0) {
align = ui::END;
} else if (strcmp(name, "between") == 0) {
align = ui::BETWEEN;
}
lua_pop(L, 1);
return align;
}
// A number pads all four sides; a table names the ones it wants.
static void readPad(lua_State* L, int table, ui::Spec& spec) {
lua_getfield(L, table, "pad");
if (lua_isnumber(L, -1)) {
uint8_t all = static_cast<uint8_t>(lua_tointeger(L, -1));
spec.padT = spec.padR = spec.padB = spec.padL = all;
} else if (lua_istable(L, -1)) {
int pad = lua_gettop(L);
spec.padT = static_cast<uint8_t>(readInt(L, pad, "t"));
spec.padR = static_cast<uint8_t>(readInt(L, pad, "r"));
spec.padB = static_cast<uint8_t>(readInt(L, pad, "b"));
spec.padL = static_cast<uint8_t>(readInt(L, pad, "l"));
}
lua_pop(L, 1);
}
static uint8_t readType(lua_State* L, int table) {
lua_getfield(L, table, "type");
const char* name = lua_isstring(L, -1) ? lua_tostring(L, -1) : "box";
uint8_t type = ui::BOX;
if (strcmp(name, "text") == 0) {
type = ui::TEXT;
} else if (strcmp(name, "button") == 0) {
type = ui::BUTTON;
} else if (strcmp(name, "custom") == 0) {
type = ui::CUSTOM;
}
lua_pop(L, 1);
return type;
}
static int l_node_reset(lua_State* L) {
tree(L).reset();
return 0;
}
static int l_node_create(lua_State* L) {
uint16_t parent = lua_isnoneornil(L, 1) ? ui::NONE : checkId(L, 1);
luaL_checktype(L, 2, LUA_TTABLE);
int spec_index = 2;
ui::Spec spec;
spec.w = readSize(L, spec_index, "w");
spec.h = readSize(L, spec_index, "h");
spec.gap = static_cast<uint8_t>(readInt(L, spec_index, "gap"));
spec.align = readAlign(L, spec_index, "align");
spec.justify = readAlign(L, spec_index, "justify");
readPad(L, spec_index, spec);
lua_getfield(L, spec_index, "at");
if (lua_istable(L, -1)) {
spec.absolute = true;
spec.atX = readSize(L, lua_gettop(L), "x");
spec.atY = readSize(L, lua_gettop(L), "y");
}
lua_pop(L, 1);
uint8_t flags = 0;
if (readBool(L, spec_index, "row")) flags |= ui::ROW;
if (readBool(L, spec_index, "capture")) flags |= ui::CAPTURE;
if (readBool(L, spec_index, "interactive")) flags |= ui::INTERACTIVE;
// Text is measured here because the panel owns the font: a node sized in Lua would
// need the metrics bindings for every label on every build.
uint8_t type = readType(L, spec_index);
lua_getfield(L, spec_index, "label");
const char* label = lua_tostring(L, -1);
if (label) {
int previous = app(L)->tft.textsize;
app(L)->tft.setTextSize(readInt(L, spec_index, "size", 1));
spec.intrinsicW = static_cast<int16_t>(app(L)->tft.textWidth(label));
spec.intrinsicH = static_cast<int16_t>(app(L)->tft.fontHeight());
app(L)->tft.setTextSize(previous);
}
uint16_t id = tree(L).add(parent, spec, type, flags);
if (label) tree(L).setLabel(id, label);
lua_pop(L, 1);
lua_pushinteger(L, id);
return 1;
}
static int l_node_attach(lua_State* L) {
uint16_t parent = checkId(L, 1);
uint16_t child = checkId(L, 2);
luaL_argcheck(L, child != parent, 2, "a node cannot hold itself");
luaL_argcheck(L, tree(L).nodes[child].parent == ui::NONE, 2, "node already has a parent");
tree(L).attach(parent, child);
return 0;
}
// Only fills in what a node left to "auto", which is how a screen root claims the panel
// without overriding a size its caller actually asked for.
static int l_node_setSize(lua_State* L) {
uint16_t id = checkId(L, 1);
luaL_argcheck(L, id < tree(L).specs.size(), 1, "tree already laid out");
ui::Spec& spec = tree(L).specs[id];
if (spec.w.mode == ui::AUTO) spec.w = decodeSize(L, 2);
if (spec.h.mode == ui::AUTO) spec.h = decodeSize(L, 3);
return 0;
}
static int l_node_layout(lua_State* L) {
uint16_t root = checkId(L, 1);
bool ok = tree(L).layout(root, luaL_checkinteger(L, 2), luaL_checkinteger(L, 3),
luaL_checkinteger(L, 4), luaL_checkinteger(L, 5));
lua_pushboolean(L, ok);
if (ok) return 1;
lua_pushstring(L, tree(L).error ? tree(L).error : "layout failed");
return 2;
}
static int l_node_dropScratch(lua_State* L) {
tree(L).dropScratch();
return 0;
}
static int l_node_hit(lua_State* L) {
uint16_t root = checkId(L, 1);
uint16_t found = tree(L).hit(root, luaL_checkinteger(L, 2), luaL_checkinteger(L, 3));
if (found == ui::NONE) return 0;
lua_pushinteger(L, found);
return 1;
}
static int l_node_getRect(lua_State* L) {
const ui::Node& n = tree(L).nodes[checkId(L, 1)];
lua_pushinteger(L, n.x);
lua_pushinteger(L, n.y);
lua_pushinteger(L, n.w);
lua_pushinteger(L, n.h);
return 4;
}
static int l_node_setLabel(lua_State* L) {
uint16_t id = checkId(L, 1);
tree(L).setLabel(id, luaL_checkstring(L, 2));
return 0;
}
static int l_node_getLabel(lua_State* L) {
const char* label = tree(L).label(checkId(L, 1));
if (!label) return 0;
lua_pushstring(L, label);
return 1;
}
static int l_node_getParent(lua_State* L) {
uint16_t parent = tree(L).nodes[checkId(L, 1)].parent;
if (parent == ui::NONE) return 0;
lua_pushinteger(L, parent);
return 1;
}
static int l_node_getCount(lua_State* L) {
lua_pushinteger(L, static_cast<lua_Integer>(tree(L).nodes.size()));
return 1;
}
static int l_node_setStyle(lua_State* L) {
uint16_t id = checkId(L, 1);
luaL_checktype(L, 2, LUA_TTABLE);
ui::Style& style = tree(L).styleFor(id);
struct Role {
const char* key;
uint16_t field;
uint16_t ui::Style::*slot;
};
static const Role roles[] = {
{"color", ui::S_FG, &ui::Style::fg},
{"bg", ui::S_BG, &ui::Style::bg},
{"fill", ui::S_FILL, &ui::Style::fill},
{"border", ui::S_BORDER, &ui::Style::border},
{"press_color", ui::S_PRESS_FG, &ui::Style::pressFg},
};
for (const Role& role : roles) {
lua_getfield(L, 2, role.key);
if (lua_isnumber(L, -1)) {
style.*(role.slot) = static_cast<uint16_t>(lua_tointeger(L, -1));
style.set |= role.field;
}
lua_pop(L, 1);
}
// Gradients arrive as the {top, bottom} pair the palette derives them in, so a theme
// cannot set half of one.
struct Pair {
const char* key;
uint16_t field;
uint16_t ui::Style::*top;
uint16_t ui::Style::*bottom;
};
static const Pair pairs[] = {
{"face", ui::S_FACE, &ui::Style::faceTop, &ui::Style::faceBottom},
{"face_pressed", ui::S_PRESSED, &ui::Style::pressTop, &ui::Style::pressBottom},
};
for (const Pair& pair : pairs) {
lua_getfield(L, 2, pair.key);
if (lua_istable(L, -1)) {
lua_rawgeti(L, -1, 1);
lua_rawgeti(L, -2, 2);
style.*(pair.top) = static_cast<uint16_t>(lua_tointeger(L, -2));
style.*(pair.bottom) = static_cast<uint16_t>(lua_tointeger(L, -1));
style.set |= pair.field;
lua_pop(L, 2);
}
lua_pop(L, 1);
}
lua_getfield(L, 2, "radius");
if (lua_isnumber(L, -1)) {
style.radius = static_cast<uint8_t>(lua_tointeger(L, -1));
style.set |= ui::S_RADIUS;
}
lua_pop(L, 1);
lua_getfield(L, 2, "size");
if (lua_isnumber(L, -1)) {
style.size = static_cast<uint8_t>(lua_tointeger(L, -1));
style.set |= ui::S_SIZE;
}
lua_pop(L, 1);
lua_getfield(L, 2, "text_align");
bool hasAlign = lua_isstring(L, -1);
lua_pop(L, 1);
if (hasAlign) {
style.textAlign = readAlign(L, 2, "text_align");
style.set |= ui::S_TEXT_ALIGN;
}
return 0;
}
static int l_node_invalidate(lua_State* L) {
tree(L).nodes[checkId(L, 1)].flags |= ui::DIRTY;
return 0;
}
static int l_node_setPressed(lua_State* L) {
ui::Node& n = tree(L).nodes[checkId(L, 1)];
if (lua_toboolean(L, 2)) {
n.flags |= ui::PRESSED;
} else {
n.flags &= ~ui::PRESSED;
}
n.flags |= ui::DIRTY;
return 0;
}
static int l_node_isPressed(lua_State* L) {
lua_pushboolean(L, (tree(L).nodes[checkId(L, 1)].flags & ui::PRESSED) != 0);
return 1;
}
static int l_node_setPainter(lua_State* L) {
luaL_checktype(L, 1, LUA_TFUNCTION);
lua_pushvalue(L, 1);
lua_setfield(L, LUA_REGISTRYINDEX, PAINTER_KEY);
return 0;
}
static void callPainter(void* context, uint16_t id, int x, int y, int w, int h) {
lua_State* L = static_cast<lua_State*>(context);
lua_getfield(L, LUA_REGISTRYINDEX, PAINTER_KEY);
if (!lua_isfunction(L, -1)) {
lua_pop(L, 1);
return;
}
lua_pushinteger(L, id);
lua_pushinteger(L, x);
lua_pushinteger(L, y);
lua_pushinteger(L, w);
lua_pushinteger(L, h);
// A painter that errors must not abandon the rest of the tree unpainted, which is why
// this is pcall: one broken widget leaves a hole, not a blank screen.
if (lua_pcall(L, 5, 0, 0) != LUA_OK) {
log_e("node painter: %s", lua_tostring(L, -1));
lua_pop(L, 1);
}
}
static int l_node_draw(lua_State* L) {
uint16_t root = checkId(L, 1);
ui::Painter painter(app(L)->tft, tree(L));
painter.custom = callPainter;
painter.context = L;
painter.draw(root);
return 0;
}
static int l_node_getFootprint(lua_State* L) {
lua_pushinteger(L, static_cast<lua_Integer>(tree(L).footprint()));
return 1;
}
void registerNode(lua_State* L) {
static const luaL_Reg lib[] = {
// --- Drops the whole tree. Every screen is built from scratch, so this is what a
// --- rebuild starts with; existing ids are invalid afterwards.
{"reset", l_node_reset},
// --- Adds a node and returns its handle.
// @param parent integer|nil Nil creates a root.
// @param spec table Fields: type ("box", "text", "button", "custom"), w, h, pad,
// @param spec table gap, align, justify, row, at, capture, interactive, label, size.
// @return integer
{"create", l_node_create},
// --- Adopts an existing root node as a child, so a container can be built after
// --- the things it holds. The child must not already have a parent.
// @param parent integer
// @param child integer
{"attach", l_node_attach},
// --- Fills in sizes a node left as "auto". Only meaningful before layout.
// @param id integer
// @param w number|string|nil
// @param h number|string|nil
{"setSize", l_node_setSize},
// --- Measures and places a tree into the given rectangle.
// @param root integer
// @param x integer
// @param y integer
// @param w integer
// @param h integer
// @return boolean True on success; false plus a message when a size cannot resolve.
{"layout", l_node_layout},
// --- Frees the layout inputs, which nothing reads once a tree is placed. A screen
// --- calls this after layout and rebuilds from Lua if it ever needs placing again.
{"dropScratch", l_node_dropScratch},
// --- Deepest interactive node covering the point, or nil.
// @param root integer
// @param x integer
// @param y integer
// @return integer|nil
{"hit", l_node_hit},
// --- Placed rectangle of a node.
// @param id integer
// @return integer x
// @return integer y
// @return integer w
// @return integer h
{"getRect", l_node_getRect},
// --- Replaces a node's text. Re-measuring is the caller's business: the node keeps
// --- the box it was placed with until the screen is rebuilt.
// @param id integer
// @param text string
{"setLabel", l_node_setLabel},
// --- A node's text, or nil if it has none.
// @param id integer
// @return string|nil
{"getLabel", l_node_getLabel},
// --- The node that contains this one, or nil at the root.
// @param id integer
// @return integer|nil
{"getParent", l_node_getParent},
// --- Sets the colors and metrics a node states for itself. Anything left out is
// --- answered by the nearest ancestor that states it, so a node that names nothing
// --- costs nothing.
// @param id integer
// @param style table Fields: color, bg, fill, border, press_color, radius, size,
// @param style table and the {top, bottom} pairs face and face_pressed.
{"setStyle", l_node_setStyle},
// --- Marks a node for repainting on the next draw, along with its children.
// @param id integer
{"invalidate", l_node_invalidate},
// --- Shows or clears a node's pressed face.
// @param id integer
// @param on boolean
{"setPressed", l_node_setPressed},
// --- Whether a node is currently showing its pressed face.
// @param id integer
// @return boolean
{"isPressed", l_node_isPressed},
// --- Installs the function that paints "custom" nodes, called with the node id and
// --- its placed rectangle. One painter serves the whole tree.
// @param painter function
{"setPainter", l_node_setPainter},
// --- Repaints every node marked dirty, and everything inside one.
// @param root integer
{"draw", l_node_draw},
// --- How many nodes the current tree holds.
// @return integer
{"getCount", l_node_getCount},
// --- Bytes the current tree occupies, for the memory the design exists to save.
// @return integer
{"getFootprint", l_node_getFootprint},
{nullptr, nullptr}};
luaL_newlib(L, lib);
lua_setglobal(L, "node");
}
+4
View File
@@ -114,6 +114,9 @@ bool LuaApp::load(const char* path, bool hasBack, const char* arg) {
// Cleared per app: the interval outlives the app that set it, so a ticking app
// followed by one that never ticks would keep calling a nil global.
tickIntervalMs = 0;
// Same reason, and the arena is the bigger one: node handles mean nothing to the next
// lua_State, so an app that inherited the last one's tree would build onto its nodes.
tree.reset();
// The tap that launched this app may still be down; swallow that gesture's release.
lastTouched = true;
ignoreRelease = true;
@@ -213,6 +216,7 @@ void LuaApp::setTickInterval(uint32_t intervalMs) {
void LuaApp::registerBindings() {
registerGui(state);
registerNode(state);
registerSys(state);
registerSettings(state);
registerInput(state);
+5 -1
View File
@@ -7,10 +7,15 @@ extern "C" {
#include <TFT_eSPI.h>
#include <XPT2046_Touchscreen.h>
#include "../ui/layout.h"
class LuaApp {
public:
TFT_eSPI& tft; // accessed by the C binding shims in the .cpp
XPT2046_Touchscreen& touch;
// One screen at a time: a rebuild resets the arena, which is why the heap returns to
// the same shape after every one instead of fragmenting.
ui::Tree tree;
LuaApp(TFT_eSPI& tft, XPT2046_Touchscreen& touch);
~LuaApp();
@@ -70,7 +75,6 @@ class LuaApp {
void mapTouch(const TS_Point& p, int16_t& x, int16_t& y) const;
static constexpr size_t READ_CAP = 64 * 1024;
static constexpr int MAX_SPAN = 480; // longest panel edge, so one row buffer covers any shape
private:
lua_State* state = nullptr;
+421
View File
@@ -0,0 +1,421 @@
#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 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_FG = 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 << 9,
S_BORDER = 1 << 3,
S_FACE = 1 << 4, // button gradient, top and bottom
S_PRESSED = 1 << 5, // pressed gradient
S_PRESS_FG = 1 << 6,
S_RADIUS = 1 << 7,
S_SIZE = 1 << 8,
S_TEXT_ALIGN = 1 << 10,
};
// Colours are RGB565, matching the panel and gui.color().
struct Style {
uint16_t fg = 0x0000;
uint16_t bg = 0xFFFF;
uint16_t fill = 0xFFFF;
uint16_t border = 0x0000;
uint16_t faceTop = 0xFFFF, faceBottom = 0xFFFF;
uint16_t pressTop = 0x0000, pressBottom = 0x0000;
uint16_t pressFg = 0xFFFF;
uint8_t radius = 6;
uint8_t size = 1;
Align textAlign = START; // within the node's own box, which a text node usually fills
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;
void reset() {
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
+184
View File
@@ -0,0 +1,184 @@
#pragma once
// Painting the node tree. Needs the panel, so unlike layout.h this is not host-testable;
// keep anything that can be decided without pixels on the other side of that line.
//
// Repainting follows the Lua original: 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 -- a widget that wants to repaint part of itself is a CUSTOM node and does
// it through the gui bindings, which is what the on-screen keyboard already does.
#include <TFT_eSPI.h>
#include "../gfx/round_rect.h"
#include "layout.h"
namespace ui {
constexpr int MAX_SPAN = 480; // longest panel edge, so one row buffer covers any shape
// One primitive draws the whole surface: fill (solid or vertical gradient) and border
// derive from the same distance field, so they cannot disagree at the corners the way
// two separate rounded-rect algorithms did. The panel has no alpha, so edge pixels are
// blended against `surface`, the colour of whatever sits underneath.
inline void drawRoundRect(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) {
if (w <= 0 || h <= 0 || w > MAX_SPAN) return;
float halfWidth = w * 0.5f, halfHeight = h * 0.5f;
if (radius < 0.0f) radius = 0.0f;
float limit = (w < h ? w : h) / 2.0f;
if (radius > limit) radius = limit;
static uint16_t span[MAX_SPAN];
// pushImage sends the buffer verbatim, but the panel wants each colour big-endian.
bool previousSwap = tft.getSwapBytes();
tft.setSwapBytes(true);
for (int row = 0; row < h; row++) {
uint16_t fill = hasFill ? gfx::lerp565(top, bottom, row, h - 1) : 0;
float py = row + 0.5f - halfHeight;
for (int column = 0; column < w; column++) {
float distance =
gfx::roundRectDistance(column + 0.5f - halfWidth, py, halfWidth, halfHeight, radius);
float outer = gfx::coverage(distance);
// The border is the ring between the shape and the same shape inset by its width.
float inner = hasBorder ? gfx::coverage(distance + 1.0f) : outer;
uint16_t pixel = surface;
if (hasFill) pixel = gfx::blend565(pixel, fill, inner);
if (hasBorder) pixel = gfx::blend565(pixel, border, outer - inner);
span[column] = pixel;
}
tft.pushImage(x, y + row, w, 1, span);
}
tft.setSwapBytes(previousSwap);
}
// A CUSTOM node paints through Lua, so the walk needs a way back. One dispatcher for the
// whole tree rather than a reference per node: the Lua side already keys its painters by
// node id and can look one up faster than the registry can hand it over.
typedef void (*CustomPainter)(void* context, uint16_t id, int x, int y, int w, int h);
class Painter {
public:
Painter(TFT_eSPI& tft, Tree& tree) : tft(tft), tree(tree) {}
CustomPainter custom = nullptr;
void* context = nullptr;
void draw(uint16_t id) {
Node& n = tree.nodes[id];
if (n.flags & DIRTY) {
paint(id);
n.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);
}
private:
TFT_eSPI& tft;
Tree& tree;
// 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, not into the lit palette the layer
// hands its children. Nothing filling means the panel, cleared to the root's colour.
uint16_t surfaceOf(uint16_t id) const {
for (uint16_t n = tree.nodes[id].parent; n != NONE; n = tree.nodes[n].parent) {
const Style* style = tree.styleOf(n);
if (style && (style->set & S_FILL)) return style->fill;
}
uint16_t root = id;
while (tree.nodes[root].parent != NONE) root = tree.nodes[root].parent;
return tree.inherited(root, S_BG).bg;
}
void paint(uint16_t id) {
const Node& n = tree.nodes[id];
switch (n.type) {
case BUTTON:
paintButton(id);
break;
case TEXT:
paintText(id);
break;
case CUSTOM:
// Cleared first, because a custom painter draws what it wants and nothing knows
// what it drew last time. The keyboard's number page is narrower than its letter
// page, and without this the wider row's outer keys survive the repaint.
tft.fillRect(n.x, n.y, n.w, n.h, tree.inherited(id, S_BG).bg);
if (custom) custom(context, id, n.x, n.y, n.w, n.h);
break;
default:
paintBox(id);
break;
}
}
// A bordered box paints its own background as a rounded rect. Filling a square first
// would leave corners outside the border, which is invisible against a matching
// surface and obvious against any other.
void paintBox(uint16_t id) {
const Node& n = tree.nodes[id];
const Style* own = tree.styleOf(id);
bool hasBorder = own && (own->set & S_BORDER);
bool hasFill = own && (own->set & S_FILL);
if (!hasBorder) {
if (hasFill) tft.fillRect(n.x, n.y, n.w, n.h, own->fill);
return;
}
uint16_t fill = hasFill ? own->fill : tree.inherited(id, S_BG).bg;
drawRoundRect(tft, n.x, n.y, n.w, n.h, tree.inherited(id, S_RADIUS).radius, surfaceOf(id),
true, fill, fill, true, own->border);
}
void paintButton(uint16_t id) {
const Node& n = tree.nodes[id];
bool pressed = (n.flags & PRESSED) != 0;
const Style& face = tree.inherited(id, pressed ? S_PRESSED : S_FACE);
uint16_t top = pressed ? face.pressTop : face.faceTop;
uint16_t bottom = pressed ? face.pressBottom : face.faceBottom;
drawRoundRect(tft, n.x, n.y, n.w, n.h, tree.inherited(id, S_RADIUS).radius, surfaceOf(id),
true, top, bottom, true, tree.inherited(id, S_FG).fg);
}
// Glyphs over a button are transparent: an opaque fill is one flat colour, which
// matches only the single row of the gradient it was taken from. The face is repainted
// whenever it changes, so the label has nothing to erase.
void paintText(uint16_t id) {
const Node& n = tree.nodes[id];
const char* label = tree.label(id);
if (!label) return;
uint16_t parent = n.parent;
bool onButton = parent != NONE && tree.nodes[parent].type == BUTTON;
bool pressed = onButton && (tree.nodes[parent].flags & PRESSED);
tft.setTextSize(tree.inherited(id, S_SIZE).size);
// A text node usually fills its parent's width, so alignment is inside its own box.
int x = n.x;
Align align = tree.inherited(id, S_TEXT_ALIGN).textAlign;
if (align == CENTER) {
x += (n.w - static_cast<int>(tft.textWidth(label))) / 2;
} else if (align == END) {
x += n.w - static_cast<int>(tft.textWidth(label));
}
if (pressed) {
tft.setTextColor(tree.inherited(id, S_PRESS_FG).pressFg);
} else if (onButton) {
tft.setTextColor(tree.inherited(id, S_FG).fg);
} else {
// The whole box is cleared, not just the glyphs: a label replaced by a shorter one
// would otherwise leave the tail of the old text standing next to the new.
uint16_t bg = tree.inherited(id, S_BG).bg;
tft.fillRect(n.x, n.y, n.w, n.h, bg);
tft.setTextColor(tree.inherited(id, S_FG).fg, bg);
}
tft.drawString(label, x, n.y);
}
};
} // namespace ui
+109
View File
@@ -218,6 +218,115 @@ function log.info(message) end
---@param message string
function log.error(message) end
---@class nodelib
node = {}
--- Drops the whole tree. Every screen is built from scratch, so this is what a
--- rebuild starts with; existing ids are invalid afterwards.
function node.reset() end
--- Adds a node and returns its handle.
---@param parent integer? Nil creates a root.
---@param spec table Fields: type ("box", "text", "button", "custom"), w, h, pad,
---@param spec table gap, align, justify, row, at, capture, interactive, label, size.
---@return integer
function node.create(parent, spec, spec) end
--- Adopts an existing root node as a child, so a container can be built after
--- the things it holds. The child must not already have a parent.
---@param parent integer
---@param child integer
function node.attach(parent, child) end
--- Fills in sizes a node left as "auto". Only meaningful before layout.
---@param id integer
---@param w number|string?
---@param h number|string?
function node.setSize(id, w, h) end
--- Measures and places a tree into the given rectangle.
---@param root integer
---@param x integer
---@param y integer
---@param w integer
---@param h integer
---@return boolean True on success; false plus a message when a size cannot resolve.
function node.layout(root, x, y, w, h) end
--- Frees the layout inputs, which nothing reads once a tree is placed. A screen
--- calls this after layout and rebuilds from Lua if it ever needs placing again.
function node.dropScratch() end
--- Deepest interactive node covering the point, or nil.
---@param root integer
---@param x integer
---@param y integer
---@return integer?
function node.hit(root, x, y) end
--- Placed rectangle of a node.
---@param id integer
---@return integer x
---@return integer y
---@return integer w
---@return integer h
function node.getRect(id) end
--- Replaces a node's text. Re-measuring is the caller's business: the node keeps
--- the box it was placed with until the screen is rebuilt.
---@param id integer
---@param text string
function node.setLabel(id, text) end
--- A node's text, or nil if it has none.
---@param id integer
---@return string?
function node.getLabel(id) end
--- The node that contains this one, or nil at the root.
---@param id integer
---@return integer?
function node.getParent(id) end
--- Sets the colors and metrics a node states for itself. Anything left out is
--- answered by the nearest ancestor that states it, so a node that names nothing
--- costs nothing.
---@param id integer
---@param style table Fields: color, bg, fill, border, press_color, radius, size,
---@param style table and the {top, bottom} pairs face and face_pressed.
function node.setStyle(id, style, style) end
--- Marks a node for repainting on the next draw, along with its children.
---@param id integer
function node.invalidate(id) end
--- Shows or clears a node's pressed face.
---@param id integer
---@param on boolean
function node.setPressed(id, on) end
--- Whether a node is currently showing its pressed face.
---@param id integer
---@return boolean
function node.isPressed(id) end
--- Installs the function that paints "custom" nodes, called with the node id and
--- its placed rectangle. One painter serves the whole tree.
---@param painter function
function node.setPainter(painter) end
--- Repaints every node marked dirty, and everything inside one.
---@param root integer
function node.draw(root) end
--- How many nodes the current tree holds.
---@return integer
function node.getCount() end
--- Bytes the current tree occupies, for the memory the design exists to save.
---@return integer
function node.getFootprint() end
---@class settingslib
settings = {}
+77
View File
@@ -39,6 +39,38 @@ end
function device.install()
device.painted = {}
-- The widget tree lives in the firmware. Geometry -- layout, hit testing, style
-- inheritance -- is asserted in test/ui_layout_test.cpp against the same C++ the panel
-- runs, so this fake deliberately implements none of it. What it does keep is the
-- structure an app builds, which is what app logic is actually about: a test taps the
-- control labelled "Rotation" rather than the pixel it happened to land on.
local nodes = {}
device.nodes = nodes
device.drawn = 0
device.tapTarget = nil
node = setmetatable({
reset = function()
for index in ipairs(nodes) do nodes[index] = nil end
end,
create = function(parent, spec)
nodes[#nodes + 1] = {parent = parent, label = spec.label, kind = spec.type,
interactive = spec.interactive or spec.capture}
return #nodes
end,
attach = function(parent, child) nodes[child].parent = parent end,
getParent = function(id) return nodes[id].parent end,
getLabel = function(id) return nodes[id].label end,
setLabel = function(id, text) nodes[id].label = text end,
getCount = function() return #nodes end,
layout = function() return true end,
-- Any point is inside, because which node a point covers is geometry and geometry is
-- not this file's business. Tests choose the target with device.tap().
getRect = function() return 0, 0, 10000, 10000 end,
hit = function() return device.tapTarget end,
draw = function() device.drawn = device.drawn + 1 end,
}, {__index = function() return function() end end})
gui = {
color = function(r, g, b) return r * 65536 + g * 256 + b end,
-- Rotation swaps the frame, exactly as TFT_eSPI reports it.
@@ -125,4 +157,49 @@ function device.install()
return device
end
-- The first node whose text starts with `prefix`, whether or not anything can press it.
function device.labelled(prefix)
for id, entry in ipairs(device.nodes) do
if entry.label and entry.label:sub(1, #prefix) == prefix then return id end
end
end
-- The nearest ancestor of that text that can be pressed: a card's label is a child of the
-- button, and it is the button a finger lands on.
function device.find(prefix)
for id, entry in ipairs(device.nodes) do
if entry.label and entry.label:sub(1, #prefix) == prefix then
local target = id
while target and not device.nodes[target].interactive do
target = device.nodes[target].parent
end
if target then return target end
end
end
end
-- Presses the control labelled `prefix`, the way a test means it: by what it says, not by
-- where it landed.
function device.tap(prefix)
local target = device.find(prefix)
if not target then error("no control labelled '" .. prefix .. "'", 2) end
device.press(target, 0, 0)
end
-- Presses a node at a point, for the widgets that have no label to aim at. getRect()
-- answers the same box here as it does inside the widget, so a custom painter's own
-- geometry decides what was hit, exactly as it does on the panel.
function device.press(id, x, y)
device.tapTarget = id
on_touch_down(x, y)
on_touch_up(x, y)
device.tapTarget = nil
end
function device.findKind(kind)
for id, entry in ipairs(device.nodes) do
if entry.kind == kind then return id end
end
end
return device
+70 -99
View File
@@ -1,119 +1,90 @@
-- Run: lua test/keyboard.lua
-- The keyboard's geometry: which key covers a point, and where each key is drawn. This
-- is the part that decides what a tap enters, and it is pure, so it is asserted here.
-- Painting, press highlighting and the 80 ms hold need the panel and the node tree, so
-- they are emulator behaviour.
package.path = "sdcard/lib/?.lua;test/?.lua;" .. package.path
local device = require("fake_device").install()
local ui = require("ui")
require("fake_device").install()
local keyboard = require("keyboard")
local changes, submitted = {}, nil
local board = keyboard.new{
on_change = function(value) changes[#changes + 1] = value end,
on_submit = function(value) submitted = value end,
}
local screen = ui.screen(ui.box{pad = 8, board})
-- The board as ui.screen would place it: full width inside 8px of padding.
local RECT = {x = 8, y = 8, w = 304, h = 4 * 30 + 3 * 5}
local rounds = {}
gui.roundRect = function(x, y, w, h, radius, bg, top, bottom, border)
rounds[#rounds + 1] = {x = x, y = y, w = w, top = top, bottom = bottom, border = border}
local function keys(page)
local found = {}
keyboard.eachKey(page, RECT, function(id, label, x, y, width)
found[#found + 1] = {id = id, label = label, x = x, y = y, w = width}
end)
return found
end
local function draw()
rounds = {}
screen:draw()
local function centreOf(page, label)
for _, key in ipairs(keys(page)) do
if key.label == label then return key.x + key.w // 2, key.y + 15 end
end
error("no key labelled " .. label)
end
local function tap(x, y)
screen:down(x, y)
screen:up(x, y)
device.now = device.now + 100
screen:draw()
screen:draw()
local function entered(page, label)
local x, y = centreOf(page, label)
local id, action, char = keyboard.keyAt(page, RECT, x, y)
return id, action, char
end
local row1X = board.rect.x + (board.rect.w - 296) // 2 + 13
local row2X = board.rect.x + (board.rect.w - 266) // 2 + 13
local row3Y = board.rect.y + 85
local bottomY = board.rect.y + 120
local qx, qy = row1X, board.rect.y + 15
-- Rows are staggered and centred like a physical keyboard: ten keys, then nine, then
-- seven between two wide keys.
local lower = keys("lower")
assert(lower[1].label == "q" and lower[10].label == "p", "top row is qwertyuiop")
assert(lower[1].x == RECT.x + 4, "top row was not centred, x=" .. lower[1].x)
assert(lower[11].label == "a" and lower[11].x == RECT.x + 19,
"home row was not centred, x=" .. lower[11].x)
-- Rows are staggered and centered like a physical keyboard.
draw()
assert(rounds[1].x == board.rect.x + 4, "top row was not centered")
assert(rounds[11].x == board.rect.x + 19, "home row was not centered")
-- Every key that gets painted must be findable by a tap at its centre, or a key exists
-- that cannot be pressed.
for _, page in ipairs({"lower", "upper", "numbers", "symbols"}) do
for _, key in ipairs(keys(page)) do
local id = keyboard.keyAt(page, RECT, key.x + key.w // 2, key.y + 15)
assert(id == key.id, string.format("%s key %q at %d,%d hit %s", page, key.label, key.x,
key.y, tostring(id)))
end
end
-- Only the touched key uses the pressed palette.
local normalQ = rounds[1]
screen:down(qx, qy)
draw()
assert(#rounds == 1, "pressing q repainted " .. #rounds .. " keys")
assert(rounds[1].top ~= normalQ.top or rounds[1].bottom ~= normalQ.bottom,
"pressed q kept its normal gradient")
screen:up(qx, qy)
assert(changes[#changes] == "q", "q was not entered")
device.now = 80
draw()
assert(#rounds == 1, "releasing q repainted " .. #rounds .. " keys")
-- Gaps between keys are dead, so a tap that lands between two of them enters neither.
assert(keyboard.keyAt("lower", RECT, lower[1].x + lower[1].w + 1, lower[1].y + 15) == nil,
"the gap between q and w entered a key")
assert(keyboard.keyAt("lower", RECT, lower[1].x, RECT.y + 30 + 2) == nil,
"the gap between rows entered a key")
assert(keyboard.keyAt("lower", RECT, lower[1].x, RECT.y - 1) == nil, "above the board hit a key")
-- Starting another tap during the hold clears the old key without redrawing the board.
local rapidBoard = keyboard.new{}
local rapidScreen = ui.screen(ui.box{pad = 8, rapidBoard})
rounds = {}
rapidScreen:draw()
local rapidNormal = rounds[1].top
local rapidQx, rapidY = rapidBoard.rect.x + 17, rapidBoard.rect.y + 15
local rapidWx = rapidQx + 30
rapidScreen:down(rapidQx, rapidY)
rounds = {}
rapidScreen:draw()
rapidScreen:up(rapidQx, rapidY)
device.now = 90
rapidScreen:down(rapidWx, rapidY)
rounds = {}
rapidScreen:draw()
assert(#rounds == 2, "overlapping taps repainted " .. #rounds .. " keys")
local states = {}
for _, key in ipairs(rounds) do states[key.x] = key.top end
assert(states[rapidQx - 13] == rapidNormal, "previous key stayed pressed")
assert(states[rapidWx - 13] ~= rapidNormal, "new key was not pressed")
rapidScreen:up(rapidWx, rapidY)
device.now = 170
rounds = {}
rapidScreen:draw()
assert(#rounds == 1 and rounds[1].top == rapidNormal, "new key stayed pressed")
-- Characters report themselves; the rest report what they do.
local _, action, char = entered("lower", "q")
assert(action == "char" and char == "q", "q did not enter itself")
local _, upperAction, upperChar = entered("upper", "Q")
assert(upperAction == "char" and upperChar == "Q", "the upper page did not enter Q")
-- Sliding onto another key cancels instead of entering the release position.
screen:draw()
screen:draw()
screen:down(qx, qy)
screen:up(qx + 30, qy)
assert(#changes == 1, "sliding from q to w entered a key")
assert(select(2, entered("lower", "shift")) == "shift", "shift key")
assert(select(2, entered("lower", "<-")) == "backspace", "backspace key")
assert(select(2, entered("lower", "123")) == "mode", "mode key")
assert(select(2, entered("lower", "space")) == "space", "space key")
assert(select(2, entered("lower", "OK")) == "submit", "submit key")
-- Shift toggles case without replacing the keyboard.
local shiftX = board.rect.x + (board.rect.w - 294) // 2 + 20
tap(shiftX, row3Y)
tap(qx, qy)
assert(changes[#changes] == "qQ", "shift did not enter uppercase Q")
tap(shiftX, row3Y)
-- The number page swaps shift for the symbols toggle, which is the only key whose
-- meaning changes with the page it is on.
assert(select(2, entered("numbers", "#+=")) == "symbols", "numbers page toggles symbols")
assert(select(2, entered("symbols", "123")) == "symbols", "symbols page toggles back")
assert(select(2, entered("numbers", "ABC")) == "mode", "numbers page returns to letters")
-- 123 opens numbers, #+= opens symbols, and backspace preserves the value.
local modeX = board.rect.x + (board.rect.w - 294) // 2 + 27
tap(modeX, bottomY)
tap(qx, qy)
assert(changes[#changes] == "qQ1", "number page did not enter 1")
local symbolX = board.rect.x + (board.rect.w - 234) // 2 + 20
tap(symbolX, row3Y)
tap(qx, qy)
assert(changes[#changes] == "qQ1[", "symbol page did not enter [")
local backspaceX = board.rect.x + (board.rect.w - 234) // 2 + 214
tap(backspaceX, row3Y)
assert(changes[#changes] == "qQ1", "backspace did not remove the symbol")
-- Bottom row is ABC, a wide spacebar, then OK.
tap(modeX, bottomY)
local spaceX = board.rect.x + (board.rect.w - 294) // 2 + 145
tap(spaceX, bottomY)
local okX = board.rect.x + (board.rect.w - 294) // 2 + 265
tap(okX, bottomY)
assert(submitted == "qQ1 ", "OK lost the keyboard value")
-- Rows shift and shrink between pages, but a key id keeps its place, so the highlight
-- drawn on press lands on the key that was pressed even after the labels change.
local function positions(page)
local at = {}
for _, key in ipairs(keys(page)) do at[key.id] = key.x end
return at
end
local lowerAt, upperAt = positions("lower"), positions("upper")
for id, x in pairs(lowerAt) do
assert(upperAt[id] == x, "key " .. id .. " moved when the page changed case")
end
print("ok")
+10 -34
View File
@@ -2,48 +2,24 @@
-- The scan and keyboard screens announce themselves before work that blocks the loop for
-- seconds. on_tick runs before draw in the same pass, so anything left for draw() to paint
-- appears only after the blocking call returns -- on a panel ui.screen() has already
-- cleared. These assert the announcement is painted by the tap itself.
-- cleared. These assert the announcement is built and painted by the tap itself.
--
-- Where the notice lands is layout: test/ui_layout_test.cpp asserts centring against the
-- same C++ the panel runs.
package.path = "sdcard/lib/?.lua;test/?.lua;" .. package.path
local device = require("fake_device").install()
dofile("sdcard/apps/Settings/main.lua")
local function tapRow(prefix)
device.painted = {}
draw()
for _, item in ipairs(device.painted) do
if item.label:sub(1, #prefix) == prefix then
device.painted = {}
on_touch_down(item.x + 2, item.y + 2)
on_touch_up(item.x + 2, item.y + 2)
return
end
end
error("no row labelled '" .. prefix .. "'")
end
local function paintedLabel(prefix)
for _, item in ipairs(device.painted) do
if item.label:sub(1, #prefix) == prefix then return item end
end
return nil
end
init()
tapRow("WiFi")
tapRow("scan networks")
device.tap("WiFi")
-- Painted by the tap, with no draw() in between.
local busy = paintedLabel("scanning")
assert(busy, "the scan screen paints its notice before scanning")
local drawnBefore = device.drawn
device.tap("scan networks")
local width = #busy.label * device.charWidth
local centerX = busy.x + width / 2
local centerY = busy.y + device.fontHeight / 2
local panelW, panelH = gui.getWidth(), gui.getHeight()
assert(math.abs(centerX - panelW / 2) <= 1, "centered horizontally, got x " .. busy.x)
-- The bar is above the app's frame, so the app's own height is what it centers in.
assert(math.abs(centerY - panelH / 2) <= 1, "centered vertically, got y " .. busy.y)
-- Built and drawn by the tap, with no draw() in between.
assert(device.labelled("scanning"), "the scan screen did not build its notice")
assert(device.drawn > drawnBefore, "the scan notice was left for the next draw()")
print("ok")
+29 -34
View File
@@ -1,37 +1,14 @@
-- Run: lua test/settings_calibration.lua
-- Drives the settings app on a desktop Lua, through the shared fake device.
-- Drives the settings app on a desktop Lua, through the shared fake device. Controls are
-- pressed by label rather than by coordinate: where a row lands is layout, and layout is
-- asserted in test/ui_layout_test.cpp against the C++ the panel actually runs.
package.path = "sdcard/lib/?.lua;test/?.lua;" .. package.path
local device = require("fake_device").install()
dofile("sdcard/apps/Settings/main.lua")
local function tap(point)
on_touch_down(point.x, point.y)
on_touch_up(point.x, point.y)
end
-- Rows are found by their label rather than by a pixel row, because every hardcoded
-- coordinate silently retargets to the wrong control the day a row is inserted.
local targets = {}
local function tapRow(prefix)
device.painted = {}
draw()
local target
for _, item in ipairs(device.painted) do
targets[item.label] = item
-- First match wins: the status line at the bottom repeats a row's words back
-- ("timezone saved"), and tapping that hits nothing.
if not target and item.label:sub(1, #prefix) == prefix then target = item end
end
if not target then
for label, item in pairs(targets) do
if label:sub(1, #prefix) == prefix then target = item break end
end
end
if not target then error("no row labelled '" .. prefix .. "'") end
tap{x = target.x + 2, y = target.y + 2}
end
local tapRow = device.tap
-- A perfectly linear panel spanning raw 200..3800 over 320x480 must round-trip
-- to those same extremes from the two inset samples.
@@ -57,11 +34,11 @@ local zones = require("timezones")
init()
tapRow("Timezone")
assert(settings.getTimezone() == zones[2].tz, "timezone " .. settings.getTimezone())
device.painted = {}
draw()
assert(#device.painted == 2, "timezone repainted " .. #device.painted .. " labels instead of its card")
assert(device.painted[1].label == "Timezone" and device.painted[2].label == zones[2].name,
"timezone card did not repaint its new value")
-- The card's value is replaced in place rather than by rebuilding the screen, so the
-- other four cards keep the nodes they already had.
local beforeCycle = node.getCount()
assert(device.labelled(zones[2].name), "timezone card did not take its new value")
assert(node.getCount() == beforeCycle, "cycling the timezone rebuilt the screen")
tapRow("Timezone")
assert(settings.getTimezone() == zones[3].tz, "timezone " .. settings.getTimezone())
@@ -106,8 +83,26 @@ tapRow("scan networks")
on_tick()
tapRow("secure")
on_tick()
for key in ("qemuqemu"):gmatch(".") do tapRow(key) end
tapRow("OK")
-- The keyboard is one custom node with no child per key, so its keys are reached through
-- its own geometry rather than by label. getRect() answers the same box to the test and
-- to the widget, which is what makes the two agree on where "q" is.
local keyboard = require("keyboard")
local board = device.findKind("custom")
assert(board, "the secure network did not open a keyboard")
local boardRect = {x = 0, y = 0, w = 10000, h = 10000}
local function typeKey(label)
local pressed
keyboard.eachKey("lower", boardRect, function(_, keyLabel, x, y, width)
if keyLabel == label and not pressed then pressed = {x = x + width // 2, y = y + 15} end
end)
assert(pressed, "no key labelled " .. label)
device.press(board, pressed.x, pressed.y)
end
for key in ("qemuqemu"):gmatch(".") do typeKey(key) end
typeKey("OK")
assert(device.connected[1] == "secure" and device.connected[2] == "qemuqemu", "secure wifi password")
print("ok")
-172
View File
@@ -1,172 +0,0 @@
-- Run: lua test/ui_layout.lua
-- Asserts the rects ui.lua computes, against the shared fake device.
package.path = "sdcard/lib/?.lua;test/?.lua;" .. package.path
local device = require("fake_device").install()
local ui = require("ui")
local function rect(node)
return string.format("%d,%d %dx%d", node.rect.x, node.rect.y, node.rect.w, node.rect.h)
end
-- Vertical flow: children fill the content width, stack by their own height plus gap.
local title = ui.text("settings")
local first = ui.button{label = "one", on_press = function() end}
local second = ui.button{label = "two", on_press = function() end}
local screen = ui.screen(ui.box{pad = 12, gap = 8, title, first, second})
assert(rect(title) == "12,12 296x8", rect(title))
-- Button height is text plus its own padding: 8 + 8 + 8 = 24.
assert(rect(first) == "12,28 296x24", rect(first))
assert(rect(second) == "12,60 296x24", rect(second))
screen:draw()
device.painted = {}
first:setText("a longer label")
screen:draw()
assert(#device.painted == 1 and device.painted[1].label == "a longer label",
"changing a button label repainted other components")
local centred = ui.text("go", {w = 40, text_align = "center"})
local centredScreen = ui.screen(ui.box{centred})
device.painted = {}
centredScreen:draw()
assert(device.painted[1].x == 14, "centred text painted at " .. device.painted[1].x)
-- Fractions resolve against the parent content box, absolutes stay absolute.
local half = ui.box{w = 0.5, h = 40}
local fixed = ui.box{w = 100, h = 40}
ui.screen(ui.box{pad = 10, half, fixed})
assert(rect(half) == "10,10 150x40", rect(half))
assert(rect(fixed) == "10,50 100x40", rect(fixed))
-- Horizontal flow with centre alignment on the cross axis of a fixed-height row.
local tall = ui.box{w = 40, h = 40}
local short = ui.box{w = 40, h = 10}
local row = ui.box{row = true, gap = 6, align = "center", h = 40, tall, short}
ui.screen(ui.box{row})
assert(rect(tall) == "0,0 40x40", rect(tall))
assert(rect(short) == "46,15 40x10", rect(short))
-- justify distributes the main axis: "between" pushes the last child to the far edge,
-- which is how a header keeps a title left and a clock right without arithmetic.
local left = ui.box{w = 40, h = 10}
local right = ui.box{w = 60, h = 10}
ui.screen(ui.box{pad = 10, ui.box{row = true, justify = "between", left, right}})
assert(rect(left) == "10,10 40x10", rect(left))
assert(rect(right) == "250,10 60x10", rect(right))
-- The other modes shift the whole run rather than spreading it.
local a = ui.box{w = 40, h = 10}
local b = ui.box{w = 60, h = 10}
ui.screen(ui.box{ui.box{row = true, gap = 10, justify = "end", a, b}})
assert(rect(a) == "210,0 40x10", rect(a))
assert(rect(b) == "260,0 60x10", rect(b))
-- One child cannot be spread against anything, so "between" degrades to "start".
local only = ui.box{w = 40, h = 10}
ui.screen(ui.box{ui.box{row = true, justify = "between", only}})
assert(rect(only) == "0,0 40x10", rect(only))
-- The root always fills the screen, so alignment there spans the whole panel.
local lone = ui.box{w = 40, h = 40}
ui.screen(ui.box{align = "center", lone})
assert(rect(lone) == "140,0 40x40", rect(lone))
-- A fraction inside an auto-sized parent is a build-time error, not a silent zero.
local ok = pcall(function()
ui.screen(ui.box{ui.box{h = "auto", ui.box{w = 20, h = 0.5}}})
end)
assert(not ok, "fraction inside an auto parent must fail loudly")
-- Hit testing: deepest interactive component wins over its tappable ancestor.
local inner = ui.button{label = "inner", on_press = function() end}
local outer = ui.box{pad = 20, on_press = function() end, inner}
local nested = ui.screen(outer)
assert(outer:hit(160, 30) == inner, "inner button should win inside its rect")
assert(outer:hit(2, 2) == outer, "the container claims its own padding")
assert(outer:hit(-50, -50) == nil, "outside the tree is a miss")
-- Capture: press then release outside cancels; release inside fires once.
local fired = 0
local button = ui.button{label = "go", on_press = function() fired = fired + 1 end}
local app = ui.screen(ui.box{pad = 10, button})
app:down(100, 20)
assert(button.pressed, "press must show immediately")
app:up(300, 470)
assert(fired == 0, "release outside must cancel")
app:down(100, 20)
app:up(100, 20)
assert(fired == 1, "release inside must fire once")
-- A dialog is an ordinary node the app includes, so it covers the flow and swallows the
-- taps that would otherwise reach what is underneath it.
local underneath = 0
local answered
local buried = ui.button{label = "do not press", on_press = function() underneath = underneath + 1 end}
local dialog = ui.confirm{
title = "forget network?",
ok = "forget",
on_ok = function() answered = "ok" end,
on_cancel = function() answered = "cancel" end,
}
local dialogScreen = ui.screen(ui.box{ui.box{pad = 12, buried}, dialog})
-- The card is centered on both axes, and the layer fills the panel.
assert(rect(dialog) == "0,0 320x480", rect(dialog))
local card = dialog.children[1]
assert(card.rect.w == 272, "card width " .. card.rect.w) -- 0.85 of 320
assert(card.rect.x == 24, "card x " .. card.rect.x) -- centered horizontally
assert(card.rect.y == math.floor((480 - card.rect.h) / 2), "card y " .. card.rect.y)
-- Dimming repaints what is behind in a darkened palette, and the dialog opts out, so a
-- card sits lit over dimmed content without any pixel ever being blended.
local litText = ui.text("behind")
ui.screen(ui.box{litText})
local dimText = ui.text("behind")
local lit = ui.text("in the dialog")
ui.screen(ui.box{dimmed = true, ui.box{dimText}, ui.box{dimmed = false, lit}})
-- A black scrim leaves black text black; the surface under it is what darkens.
assert(dimText.bg ~= litText.bg, "the dimmed surface kept its background")
assert(lit.bg == litText.bg, "the dialog was dimmed along with the content behind it")
assert(ui.theme.dim.bg ~= ui.theme.bg, "the dim palette matches the lit one")
assert(ui.theme.dim.muted ~= ui.theme.muted, "dimming did not reach derived roles")
-- A bordered card blends its rounded edge into what is behind it, not into its own
-- fill, or the corners it does not cover show the wrong color.
local bordered = ui.box{border = 1, bg = 2, w = 40, h = 20}
ui.screen(ui.box{dimmed = true, bordered})
assert(bordered.surface == ui.theme.dim.bg, "card blends against its own fill, not the surface")
assert(bordered.paintsBackground, "a bordered box must suppress the square fill")
-- A tap on the button that the dialog covers must not reach it.
dialogScreen:down(60, 24)
dialogScreen:up(60, 24)
assert(underneath == 0, "the dialog was tapped through")
-- The dialog's own buttons still work.
local ok = card.children[#card.children].children[2]
local point = {x = ok.rect.x + 4, y = ok.rect.y + 4}
dialogScreen:down(point.x, point.y)
dialogScreen:up(point.x, point.y)
assert(answered == "ok", "ok button did not fire, got " .. tostring(answered))
-- setText repaints only on a real change, so a caller may push a value every tick.
local label = ui.text("12:00:00")
local clockScreen = ui.screen(ui.box{label})
clockScreen:draw()
assert(not label.dirty, "a drawn node starts clean")
label:setText("12:00:00")
assert(not label.dirty, "unchanged text must not repaint")
label:setText("12:00:01")
assert(label.dirty, "changed text must repaint")
assert(label.label == "12:00:01", label.label)
-- The pressed look is held briefly, then cleared on a later draw.
assert(button.pressed, "pressed look must outlast the release")
device.now = device.now + 100
app:draw()
assert(not button.pressed, "pressed look must clear after the hold")
print("ok")
+389
View File
@@ -0,0 +1,389 @@
// Run: make test-cpp
// The rect and hit assertions from test/ui_layout.lua, against the C++ node arena.
// Panel and font match test/fake_device.lua so the numbers are the same ones.
#include "../src/ui/layout.h"
#include <cstdio>
#include <cstring>
#include <string>
using namespace ui;
static const int PANEL_W = 320, PANEL_H = 480;
static const int CHAR_W = 6, FONT_H = 8;
static int failures = 0;
static void check(const char* what, const std::string& got, const std::string& want) {
if (got == want) return;
printf("\n %s: got %s, want %s", what, got.c_str(), want.c_str());
failures++;
}
static void checkTrue(const char* what, bool ok) {
if (ok) return;
printf("\n %s", what);
failures++;
}
static std::string rect(const Tree& tree, uint16_t id) {
const Node& n = tree.nodes[id];
char buffer[64];
snprintf(buffer, sizeof(buffer), "%d,%d %dx%d", n.x, n.y, n.w, n.h);
return buffer;
}
// Builders -----------------------------------------------------------------
static Spec boxSpec() { return Spec(); }
static Spec textSpec(const char* label) {
Spec spec;
spec.intrinsicW = static_cast<int16_t>(strlen(label) * CHAR_W);
spec.intrinsicH = FONT_H;
return spec;
}
static uint16_t addText(Tree& tree, uint16_t parent, const char* label) {
uint16_t id = tree.add(parent, textSpec(label), TEXT);
tree.setLabel(id, label);
return id;
}
// Mirrors ui.button: pad 8 all round, contents centred on the cross axis.
static uint16_t addButton(Tree& tree, uint16_t parent, const char* label, bool interactive = true) {
Spec spec;
spec.padT = spec.padR = spec.padB = spec.padL = 8;
spec.align = CENTER;
uint16_t id = tree.add(parent, spec, BUTTON, interactive ? INTERACTIVE : 0);
addText(tree, id, label);
return id;
}
// Mirrors ui.screen: the root is the panel, so it must measure that way too.
static uint16_t addRoot(Tree& tree, Spec spec = Spec()) {
spec.w = Size::fill();
spec.h = Size::fill();
return tree.add(NONE, spec);
}
static bool run(Tree& tree, uint16_t root) {
return tree.layout(root, 0, 0, PANEL_W, PANEL_H);
}
// Cases --------------------------------------------------------------------
static void flowStacksChildren() {
Tree tree;
Spec spec;
spec.padT = spec.padR = spec.padB = spec.padL = 12;
spec.gap = 8;
uint16_t root = addRoot(tree, spec);
uint16_t title = addText(tree, root, "settings");
uint16_t first = addButton(tree, root, "one");
uint16_t second = addButton(tree, root, "two");
checkTrue("flow laid out", run(tree, root));
check("title", rect(tree, title), "12,12 296x8");
// Button height is text plus its own padding: 8 + 8 + 8 = 24.
check("first button", rect(tree, first), "12,28 296x24");
check("second button", rect(tree, second), "12,60 296x24");
}
static void fractionsResolveAgainstContent() {
Tree tree;
Spec spec;
spec.padT = spec.padR = spec.padB = spec.padL = 10;
uint16_t root = addRoot(tree, spec);
Spec halfSpec = boxSpec();
halfSpec.w = Size::fraction(500);
halfSpec.h = Size::px(40);
uint16_t half = tree.add(root, halfSpec);
Spec fixedSpec = boxSpec();
fixedSpec.w = Size::px(100);
fixedSpec.h = Size::px(40);
uint16_t fixed = tree.add(root, fixedSpec);
checkTrue("fractions laid out", run(tree, root));
check("half", rect(tree, half), "10,10 150x40");
check("fixed", rect(tree, fixed), "10,50 100x40");
}
static void rowCentresOnCrossAxis() {
Tree tree;
uint16_t root = addRoot(tree);
Spec rowSpec;
rowSpec.gap = 6;
rowSpec.align = CENTER;
rowSpec.h = Size::px(40);
uint16_t row = tree.add(root, rowSpec, BOX, ROW);
Spec tallSpec;
tallSpec.w = Size::px(40);
tallSpec.h = Size::px(40);
uint16_t tall = tree.add(row, tallSpec);
Spec shortSpec;
shortSpec.w = Size::px(40);
shortSpec.h = Size::px(10);
uint16_t shortBox = tree.add(row, shortSpec);
checkTrue("row laid out", run(tree, root));
check("tall", rect(tree, tall), "0,0 40x40");
check("short", rect(tree, shortBox), "46,15 40x10");
}
// justify distributes the main axis: "between" pushes the last child to the far edge,
// which is how a header keeps a title left and a clock right without arithmetic.
static void justifyBetweenSpreads() {
Tree tree;
Spec rootSpec;
rootSpec.padT = rootSpec.padR = rootSpec.padB = rootSpec.padL = 10;
uint16_t root = addRoot(tree, rootSpec);
Spec rowSpec;
rowSpec.justify = BETWEEN;
uint16_t row = tree.add(root, rowSpec, BOX, ROW);
Spec leftSpec;
leftSpec.w = Size::px(40);
leftSpec.h = Size::px(10);
uint16_t left = tree.add(row, leftSpec);
Spec rightSpec;
rightSpec.w = Size::px(60);
rightSpec.h = Size::px(10);
uint16_t right = tree.add(row, rightSpec);
checkTrue("between laid out", run(tree, root));
check("left", rect(tree, left), "10,10 40x10");
check("right", rect(tree, right), "250,10 60x10");
}
// The other modes shift the whole run rather than spreading it.
static void justifyEndShiftsRun() {
Tree tree;
uint16_t root = addRoot(tree);
Spec rowSpec;
rowSpec.gap = 10;
rowSpec.justify = END;
uint16_t row = tree.add(root, rowSpec, BOX, ROW);
Spec aSpec;
aSpec.w = Size::px(40);
aSpec.h = Size::px(10);
uint16_t a = tree.add(row, aSpec);
Spec bSpec;
bSpec.w = Size::px(60);
bSpec.h = Size::px(10);
uint16_t b = tree.add(row, bSpec);
checkTrue("end laid out", run(tree, root));
check("a", rect(tree, a), "210,0 40x10");
check("b", rect(tree, b), "260,0 60x10");
}
// One child cannot be spread against anything, so "between" degrades to "start".
static void betweenWithOneChild() {
Tree tree;
uint16_t root = addRoot(tree);
Spec rowSpec;
rowSpec.justify = BETWEEN;
uint16_t row = tree.add(root, rowSpec, BOX, ROW);
Spec onlySpec;
onlySpec.w = Size::px(40);
onlySpec.h = Size::px(10);
uint16_t only = tree.add(row, onlySpec);
checkTrue("lone child laid out", run(tree, root));
check("only", rect(tree, only), "0,0 40x10");
}
// The root always fills the screen, so alignment there spans the whole panel.
static void alignCentresOnTheRoot() {
Tree tree;
Spec rootSpec;
rootSpec.align = CENTER;
uint16_t root = addRoot(tree, rootSpec);
Spec loneSpec;
loneSpec.w = Size::px(40);
loneSpec.h = Size::px(40);
uint16_t lone = tree.add(root, loneSpec);
checkTrue("centred root laid out", run(tree, root));
check("lone", rect(tree, lone), "140,0 40x40");
}
// A fraction inside an auto-sized parent is an error, not a silent zero.
static void fractionInsideAutoParentFails() {
Tree tree;
uint16_t root = addRoot(tree);
uint16_t autoBox = tree.add(root, boxSpec());
Spec childSpec;
childSpec.w = Size::px(20);
childSpec.h = Size::fraction(500);
tree.add(autoBox, childSpec);
checkTrue("fraction inside an auto parent must fail loudly", !run(tree, root));
}
// Hit testing: deepest interactive node wins over its tappable ancestor.
static void hitPrefersTheDeepestNode() {
Tree tree;
Spec outerSpec;
outerSpec.padT = outerSpec.padR = outerSpec.padB = outerSpec.padL = 20;
outerSpec.w = Size::fill();
outerSpec.h = Size::fill();
uint16_t outer = tree.add(NONE, outerSpec, BOX, INTERACTIVE);
uint16_t inner = addButton(tree, outer, "inner");
checkTrue("hit tree laid out", run(tree, outer));
checkTrue("inner button should win inside its rect", tree.hit(outer, 160, 30) == inner);
checkTrue("the container claims its own padding", tree.hit(outer, 2, 2) == outer);
checkTrue("outside the tree is a miss", tree.hit(outer, -50, -50) == NONE);
}
// A dialog is an ordinary node the app includes, placed absolutely so it covers the flow
// and swallows the taps that would otherwise reach what is underneath it.
static void dialogCoversAndCaptures() {
Tree tree;
uint16_t root = addRoot(tree);
Spec behindSpec;
behindSpec.padT = behindSpec.padR = behindSpec.padB = behindSpec.padL = 12;
uint16_t behind = tree.add(root, behindSpec);
uint16_t buried = addButton(tree, behind, "do not press");
Spec layerSpec;
layerSpec.absolute = true;
layerSpec.atX = Size::px(0);
layerSpec.atY = Size::px(0);
layerSpec.w = Size::fill();
layerSpec.h = Size::fill();
layerSpec.align = CENTER;
layerSpec.justify = CENTER;
uint16_t layer = tree.add(root, layerSpec, BOX, CAPTURE);
Spec cardSpec;
cardSpec.w = Size::fraction(850);
cardSpec.padT = cardSpec.padR = cardSpec.padB = cardSpec.padL = 16;
cardSpec.gap = 12;
uint16_t card = tree.add(layer, cardSpec);
addText(tree, card, "forget network?");
Spec buttonsSpec;
buttonsSpec.gap = 8;
buttonsSpec.justify = END;
uint16_t buttons = tree.add(card, buttonsSpec, BOX, ROW);
addButton(tree, buttons, "cancel");
uint16_t confirm = addButton(tree, buttons, "forget");
checkTrue("dialog laid out", run(tree, root));
check("layer", rect(tree, layer), "0,0 320x480");
const Node& cardNode = tree.nodes[card];
checkTrue("card is 0.85 of the panel", cardNode.w == 272);
checkTrue("card is centred horizontally", cardNode.x == 24);
checkTrue("card is centred vertically", cardNode.y == (PANEL_H - cardNode.h) / 2);
checkTrue("the dialog was tapped through", tree.hit(root, 60, 24) == layer);
checkTrue("the dialog's own buttons still work",
tree.hit(root, tree.nodes[confirm].x + 4, tree.nodes[confirm].y + 4) == confirm);
(void)buried;
}
// Labels share one arena. Overwriting in place matters because the clock repaints every
// second at a fixed width, and an append per tick would grow without bound.
static void labelsShareOneArena() {
Tree tree;
uint16_t root = addRoot(tree);
uint16_t clock = addText(tree, root, "12:00:00");
uint16_t other = addText(tree, root, "wifi");
size_t before = tree.footprint();
for (int i = 0; i < 100; i++) tree.setLabel(clock, "12:00:01");
checkTrue("a same-width label must not grow the arena", tree.footprint() == before);
check("clock label", tree.label(clock), "12:00:01");
check("neighbour label", tree.label(other), "wifi");
tree.setLabel(clock, "a much longer label");
check("grown label", tree.label(clock), "a much longer label");
check("neighbour survived the growth", tree.label(other), "wifi");
checkTrue("a node with no label reports none", tree.label(root) == nullptr);
}
// Styling is inheritance rather than copies: the root carries the palette and a node
// that names nothing costs nothing, which is what keeps the arena at 16 bytes a node.
static void styleInheritsFromTheNearestAncestor() {
Tree tree;
uint16_t root = addRoot(tree);
uint16_t middle = tree.add(root, boxSpec());
uint16_t leaf = tree.add(middle, boxSpec());
Style& palette = tree.styleFor(root);
palette.fg = 0x0001;
palette.bg = 0x0002;
palette.set = S_FG | S_BG;
Style& override_ = tree.styleFor(leaf);
override_.fg = 0x0003;
override_.set = S_FG;
checkTrue("a leaf inherits what it does not state", tree.inherited(leaf, S_BG).bg == 0x0002);
checkTrue("a leaf keeps what it does state", tree.inherited(leaf, S_FG).fg == 0x0003);
checkTrue("an unstyled node inherits both", tree.inherited(middle, S_FG).fg == 0x0001);
checkTrue("an unset role falls back rather than guessing",
tree.inherited(leaf, S_BORDER).border == Style().border);
checkTrue("only styled nodes cost anything", tree.styleOf(middle) == nullptr);
}
// The point of the split arena: what a screen costs once its layout pass is over.
static void reportFootprint() {
Tree tree;
uint16_t root = addRoot(tree);
for (int i = 0; i < 200; i++) addButton(tree, root, "item");
size_t built = tree.nodes.size();
checkTrue("footprint tree laid out", run(tree, root));
size_t scratch = built * sizeof(Spec);
tree.dropScratch();
size_t persistent = tree.footprint();
printf("\n sizeof(Node)=%zu sizeof(Spec)=%zu", sizeof(Node), sizeof(Spec));
printf("\n %zu nodes: %zu B steady, %zu B peak (+%zu B scratch)", built, persistent,
persistent + scratch, scratch);
checkTrue("a Node must stay at 16 bytes", sizeof(Node) == 16);
}
int main() {
flowStacksChildren();
fractionsResolveAgainstContent();
rowCentresOnCrossAxis();
justifyBetweenSpreads();
justifyEndShiftsRun();
betweenWithOneChild();
alignCentresOnTheRoot();
fractionInsideAutoParentFails();
hitPrefersTheDeepestNode();
dialogCoversAndCaptures();
labelsShareOneArena();
styleInheritsFromTheNearestAncestor();
reportFootprint();
if (failures) {
printf("\n%d failure(s)\n", failures);
return 1;
}
printf("\n ok\n");
return 0;
}
+7 -13
View File
@@ -45,19 +45,13 @@ assert(light.radius == 6, "light keeps the derived radius")
local missing = reload("does-not-exist")
assert(missing.bg == WHITE and missing.fg == BLACK, "unknown theme falls back to light")
-- The root seeds the tree, so an app naming no colors still gets themed.
reload("dark")
local button = ui.button{label = "go", on_press = function() end}
local screen = ui.screen(ui.box{pad = 12, button})
assert(screen.root.bg == ui.theme.bg, "root takes the theme surface")
assert(screen.root.color == ui.theme.fg, "root takes the theme text")
screen:draw()
assert(button.color == ui.theme.fg, "the button inherited the theme")
assert(button.press_bg == ui.theme.accent, "the button inherited the accent")
-- Dimming re-derives every role rather than blending pixels, because the panel has no
-- alpha to composite a scrim with.
reload("light")
assert(ui.theme.dim.bg ~= ui.theme.bg, "the dim palette matches the lit one")
assert(ui.theme.dim.muted ~= ui.theme.muted, "dimming did not reach derived roles")
-- An explicit value still wins over the theme.
local custom = ui.button{label = "delete", press_bg = 123, on_press = function() end}
ui.screen(ui.box{pad = 12, custom}):draw()
assert(custom.press_bg == 123, "explicit styling overrides the theme")
-- Which node ends up wearing which role is inheritance, and inheritance is the firmware's
-- half: test/ui_layout_test.cpp asserts it against the same code the panel runs.
print("ok")