diff --git a/AGENTS.md b/AGENTS.md index 9e66a8d..04a0239 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/Makefile b/Makefile index bb1296f..43bc091 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/sdcard/apps/Keyboard/main.lua b/sdcard/apps/Keyboard/main.lua index 02c063a..eea715b 100644 --- a/sdcard/apps/Keyboard/main.lua +++ b/sdcard/apps/Keyboard/main.lua @@ -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() diff --git a/sdcard/apps/Settings/main.lua b/sdcard/apps/Settings/main.lua index 7476b6b..41ebd3d 100644 --- a/sdcard/apps/Settings/main.lua +++ b/sdcard/apps/Settings/main.lua @@ -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() diff --git a/sdcard/lib/keyboard.lua b/sdcard/lib/keyboard.lua index a148e2f..a0eb74e 100644 --- a/sdcard/lib/keyboard.lua +++ b/sdcard/lib/keyboard.lua @@ -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 diff --git a/sdcard/lib/ui.lua b/sdcard/lib/ui.lua index a4d39e7..31bb785 100644 --- a/sdcard/lib/ui.lua +++ b/sdcard/lib/ui.lua @@ -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 diff --git a/src/lua/bindings.h b/src/lua/bindings.h index 9f0ba9c..bf6b655 100644 --- a/src/lua/bindings.h +++ b/src/lua/bindings.h @@ -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); diff --git a/src/lua/bindings/gui.cpp b/src/lua/bindings/gui.cpp index 2ccb462..aaa63b7 100644 --- a/src/lua/bindings/gui.cpp +++ b/src/lua/bindings/gui.cpp @@ -2,7 +2,7 @@ #include -#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; } diff --git a/src/lua/bindings/node.cpp b/src/lua/bindings/node.cpp new file mode 100644 index 0000000..46d2eab --- /dev/null +++ b/src/lua/bindings/node.cpp @@ -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 + +#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(id) < tree(L).nodes.size(), index, + "no such node"); + return static_cast(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(value * 1000.0 + 0.5)) + : ui::Size::px(static_cast(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(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(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(readInt(L, pad, "t")); + spec.padR = static_cast(readInt(L, pad, "r")); + spec.padB = static_cast(readInt(L, pad, "b")); + spec.padL = static_cast(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(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(app(L)->tft.textWidth(label)); + spec.intrinsicH = static_cast(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(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(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(lua_tointeger(L, -2)); + style.*(pair.bottom) = static_cast(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(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(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(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(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"); +} diff --git a/src/lua/lua_app.cpp b/src/lua/lua_app.cpp index 0f9c396..86ea8a7 100644 --- a/src/lua/lua_app.cpp +++ b/src/lua/lua_app.cpp @@ -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); diff --git a/src/lua/lua_app.h b/src/lua/lua_app.h index 9609cb0..60ba1df 100644 --- a/src/lua/lua_app.h +++ b/src/lua/lua_app.h @@ -7,10 +7,15 @@ extern "C" { #include #include +#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; diff --git a/src/ui/layout.h b/src/ui/layout.h new file mode 100644 index 0000000..b1ac3e5 --- /dev/null +++ b/src/ui/layout.h @@ -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 +#include +#include +#include +#include + +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 nodes; + std::vector 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(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(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 labelAt; + std::vector labels; + std::vector > 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(w != UNKNOWN ? w : along); + self.h = static_cast(h != UNKNOWN ? h : across); + } else { + self.w = static_cast(w != UNKNOWN ? w : across); + self.h = static_cast(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(x); + self.y = static_cast(y); + self.w = static_cast(w); + self.h = static_cast(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 diff --git a/src/ui/paint.h b/src/ui/paint.h new file mode 100644 index 0000000..2af2964 --- /dev/null +++ b/src/ui/paint.h @@ -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 + +#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(tft.textWidth(label))) / 2; + } else if (align == END) { + x += n.w - static_cast(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 diff --git a/stubs/slate32.lua b/stubs/slate32.lua index f544a5a..4d8708d 100644 --- a/stubs/slate32.lua +++ b/stubs/slate32.lua @@ -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 = {} diff --git a/test/fake_device.lua b/test/fake_device.lua index f1e60ee..0b6faf2 100644 --- a/test/fake_device.lua +++ b/test/fake_device.lua @@ -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 diff --git a/test/keyboard.lua b/test/keyboard.lua index 118bfa1..f044b0f 100644 --- a/test/keyboard.lua +++ b/test/keyboard.lua @@ -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") diff --git a/test/settings_busy.lua b/test/settings_busy.lua index 5213623..f51b3c6 100644 --- a/test/settings_busy.lua +++ b/test/settings_busy.lua @@ -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") diff --git a/test/settings_calibration.lua b/test/settings_calibration.lua index 07e8beb..63a88d4 100644 --- a/test/settings_calibration.lua +++ b/test/settings_calibration.lua @@ -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") diff --git a/test/ui_layout.lua b/test/ui_layout.lua deleted file mode 100644 index 64ea331..0000000 --- a/test/ui_layout.lua +++ /dev/null @@ -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") diff --git a/test/ui_layout_test.cpp b/test/ui_layout_test.cpp new file mode 100644 index 0000000..193dbad --- /dev/null +++ b/test/ui_layout_test.cpp @@ -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 +#include +#include + +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(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; +} diff --git a/test/ui_theme.lua b/test/ui_theme.lua index 82b7254..8b3331c 100644 --- a/test/ui_theme.lua +++ b/test/ui_theme.lua @@ -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")