feat(ui)!: move the widget tree into C++ #1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user