Files
slate32/sdcard/apps/Settings/main.lua
evan f7c5cc09ba feat(ui)!: move the widget tree into C++
A node was a Lua table of ~625 bytes, of which 21 keys pushed it over a
power-of-two hash boundary and eight were style copies inheritance had
splattered down from its parent. A 400 node screen cost ~250 KB and could not
coexist with wifi's buffers.

The tree now lives in src/ui/layout.h as a 16 byte struct in a flat arena, and
splits by lifetime: Node holds what hit testing and repainting need forever,
Spec holds what only measure/place read and is dropped when layout ends. Style
is sparse and resolved by walking parents, so a node naming no colours costs
nothing. Re-layout rebuilds from Lua rather than retaining the inputs.

    401 nodes:  8218 B steady, 21050 B peak
           Lua: ~250000 B steady

sdcard/lib/ui.lua stays the toolkit and keeps every constructor signature, but
returns integer handles: 627 lines to 374. Composition, the palette and custom
painters are still Lua on the SD card; only primitives now need a reflash.

BREAKING CHANGE: ui constructors return handles, not tables. Use
ui.setText(id, text) and keep per-node app data in a table keyed by id.
2026-08-02 18:48:48 -04:00

361 lines
11 KiB
Lua

local ui = require("ui")
-- The same module instance the firmware paints the bar with, so toggling fullscreen
-- through it drops the cache that would otherwise hide the repaint.
local statusbar = require("statusbar")
local INSET = 30
local MENU_PAD, MENU_GAP = 12, 8
local screen, message, passwordLabel, passwordRow
local mode = "menu"
local samples, pending, armed = {}, nil, false
local scanRequested, keyboardRequested = false, false
local selectedNetwork, password = nil, ""
local timezoneCard
local buildMenu, buildWifi, buildNetworks, buildKeyboard, startCalibration, cycleRotation, updatePassword
-- Two inset targets give a raw-per-pixel slope; extrapolate it to the screen edges.
-- Exposed as a global so test/settings_calibration.lua can exercise it.
function computeCalibration(s1, s2, w, h, inset)
local sx = (s2.x - s1.x) / (w - 2 * inset)
local sy = (s2.y - s1.y) / (h - 2 * inset)
return math.floor(s1.x - sx * inset), math.floor(s1.y - sy * inset),
math.floor(s2.x + sx * inset), math.floor(s2.y + sy * inset)
end
-- When set, a dialog node the current screen is rebuilt with. It is state like `mode`
-- and `message`, not something layered on afterwards, so no screen rebuild can lose it.
local dialog
local function themed(items)
-- The root carries no padding so a dialog can cover the whole panel; the padded box
-- is the content it covers. Colors come from the theme by inheritance.
local content = ui.box(items)
-- dimmed on the root, not the content: the root is the node that covers the panel, so
-- the scrim reaches the margins the content box does not.
screen = ui.screen(dialog and ui.box{dimmed = true, content, dialog} or ui.box{content})
end
local function target(n)
if n == 1 then return INSET, INSET end
return 320 - INSET, 480 - INSET
end
local function drawTarget(n)
local x, y = target(n)
-- Drawn outside the component tree, so this is the case that reads the theme directly.
local theme = ui.theme
gui.clear(theme.bg)
gui.drawText("tap the cross", 10, 10, theme.fg, theme.bg)
gui.drawLine(x - 12, y, x + 12, y, theme.accent)
gui.drawLine(x, y - 12, x, y + 12, theme.accent)
end
local function finishCalibration()
local ok = settings.setCalibration(computeCalibration(samples[1], samples[2], 320, 480, INSET))
message = ok and "calibration saved" or "save failed"
mode = "menu"
gui.setRotation(settings.getRotation())
statusbar.setFullscreen(false)
buildMenu()
end
function startCalibration()
message = nil
mode = "calibrate"
samples, pending, armed = {}, nil, false
gui.setRotation(0)
-- The targets sit at the physical corners and the samples are read in panel
-- coordinates, so the status bar cannot be allowed to shift the frame.
statusbar.setFullscreen(true)
drawTarget(1)
end
function cycleRotation()
local ok = settings.setRotation((settings.getRotation() + 90) % 360)
message = ok and "rotation saved" or "save failed"
buildMenu()
end
local function cycleTheme()
local names = ui.themeNames()
local next_index = 1
for index, name in ipairs(names) do
if name == ui.themeName then next_index = index % #names + 1 end
end
local ok = ui.setTheme(names[next_index])
message = ok and "theme saved" or "save failed"
buildMenu()
end
local zones = require("timezones")
-- The stored value is a POSIX rule, so a zone set by hand and missing from the list
-- shows its rule rather than pretending to be the first entry.
local function zoneLabel()
local current = settings.getTimezone()
for _, zone in ipairs(zones) do
if zone.tz == current then return zone.name end
end
return current
end
local function cycleTimezone()
local current = settings.getTimezone()
local next_index = 1
for index, zone in ipairs(zones) do
if zone.tz == current then next_index = index % #zones + 1 end
end
local ok = settings.setTimezone(zones[next_index].tz)
ui.setText(timezoneValue, ok and zoneLabel() or "save failed")
ui.invalidate(timezoneCard)
end
local function wifiValue(status)
if status.state == "connected" then return status.ssid end
if status.ssid ~= "" then return status.state end
return "not set"
end
local function card(side, title, value, on_press)
local spec = {
w = side, h = side, gap = 6,
justify = "center",
on_press = on_press,
ui.label(title, {fit = side - 12}),
}
local valueLabel
if value then
valueLabel = ui.text(value, {w = side - 12, text_align = "center"})
spec[#spec + 1] = valueLabel
end
local button = ui.button(spec)
if title == "Timezone" then timezoneValue = valueLabel end
return button
end
function buildMenu()
mode = "menu"
-- No title: the status bar already names the running app. The status line under the
-- grid is worth its rows, so the cards give it room rather than pushing it off screen.
local side, cols = ui.cardSide(5, MENU_PAD, MENU_GAP, message and 20 or 0)
local cards = {
card(side, "Calibrate", "touch", startCalibration),
card(side, "Rotation", settings.getRotation() .. " deg", cycleRotation),
card(side, "WiFi", wifiValue(wifi.getStatus()), buildWifi),
card(side, "Theme", ui.themeName, cycleTheme),
card(side, "Timezone", zoneLabel(), cycleTimezone),
}
timezoneCard = cards[#cards]
-- Centred left to right as a unit, like the home grid, and still top aligned.
local items = {pad = MENU_PAD, gap = MENU_GAP, w = "fill", align = "center"}
local gridW = cols * side + (cols - 1) * MENU_GAP
for index = 1, #cards, cols do
local row = {row = true, gap = MENU_GAP, w = gridW}
for column = index, math.min(index + cols - 1, #cards) do row[#row + 1] = cards[column] end
items[#items + 1] = ui.box(row)
end
if message then items[#items + 1] = ui.text(message) end
themed(items)
end
-- Painted here rather than left to draw(): the work these announce blocks the loop, and
-- on_tick runs before draw in the same pass, so the panel would sit on the background
-- ui.screen() cleared it to until the blocking call returned.
local function busy(text)
themed{w = "fill", h = "fill", justify = "center", align = "center", ui.label(text)}
screen:draw()
end
local function requestScan()
mode = "scanning"
scanRequested = true
busy("scanning networks...")
end
local function forgetNetwork()
dialog = nil
message = wifi.forget() and "wifi forgotten" or "save failed"
buildWifi()
end
-- Destructive and one tap away from the row above it, which is what a confirm is for.
local function confirmForget()
dialog = ui.confirm{
title = "forget network?",
message = wifi.getStatus().ssid,
ok = "forget",
on_ok = forgetNetwork,
on_cancel = function()
dialog = nil
buildWifi()
end,
}
buildWifi()
end
function buildWifi()
mode = "wifi"
local status = wifi.getStatus()
local items = {pad = 12, gap = 8, ui.text("wifi"), ui.text("wifi: " .. wifiValue(status))}
if status.state == "connected" then
items[#items + 1] = ui.text("ip: " .. status.ip)
end
items[#items + 1] = ui.button{label = "scan networks", on_press = requestScan}
if status.ssid ~= "" then
items[#items + 1] = ui.button{label = "forget network", on_press = confirmForget}
end
items[#items + 1] = ui.button{label = "back", on_press = buildMenu}
themed(items)
end
local function connectSelected()
if not wifi.connect(selectedNetwork.ssid, password) then
message = "could not save wifi"
buildWifi()
return
end
mode = "connecting"
themed{pad = 12, gap = 8,
ui.text("wifi"),
ui.text("connecting to " .. selectedNetwork.ssid .. "..."),
ui.button{label = "back", on_press = buildWifi},
}
end
local function chooseNetwork(network)
selectedNetwork, password = {ssid = network.ssid}, ""
if not network.secure then
connectSelected()
return
end
keyboardRequested = true
mode = "password"
busy("opening keyboard...")
end
-- 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)
mode = "networks"
local byName = {}
for _, network in ipairs(networks) do
local current = byName[network.ssid]
if network.ssid ~= "" and (not current or network.rssi > current.rssi) then
byName[network.ssid] = network
end
end
local list = {}
for _, network in pairs(byName) do list[#list + 1] = network end
table.sort(list, function(a, b) return a.rssi > b.rssi end)
local items = {pad = 12, gap = 6, ui.text("wifi networks")}
for i = 1, math.min(#list, 6) do
local network = list[i]
local lock = network.secure and " *" or ""
local card = ui.button{
label = network.ssid .. lock .. " " .. network.rssi,
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
items[#items + 1] = ui.button{label = "rescan", on_press = requestScan}
items[#items + 1] = ui.button{label = "back", on_press = buildWifi}
themed(items)
end
function updatePassword(value)
password = value
ui.setText(passwordLabel, "password: " .. password)
ui.invalidate(passwordRow)
end
function buildKeyboard()
screen = nil
collectgarbage()
local keyboard = require("keyboard")
mode = "password"
passwordLabel = ui.text("password: " .. password)
passwordRow = ui.box{passwordLabel}
themed{pad = 8, gap = 5,
ui.text(selectedNetwork.ssid), passwordRow,
keyboard.new{
value = password,
on_change = updatePassword,
on_submit = function(value) password = value; connectSelected() end,
},
}
local free, _, largest = sys.getMemory()
log.info("wifi keyboard ready free=" .. free .. " largest=" .. largest)
end
function init()
sys.setTickInterval(50) -- calibration samples the raw panel between draws
buildMenu()
log.info("settings ready")
end
function draw()
if mode ~= "calibrate" then screen:draw() end
end
function on_touch_down(x, y)
if mode ~= "calibrate" then screen:down(x, y) end
end
function on_touch_up(x, y)
if mode ~= "calibrate" then screen:up(x, y) end
end
function on_tick()
if keyboardRequested then
keyboardRequested = false
buildKeyboard()
return
end
if scanRequested then
scanRequested = false
buildNetworks(wifi.scan())
return
end
if mode == "connecting" then
local status = wifi.getStatus()
if status.state == "connected" then
message = "connected: " .. status.ip
local free, _, largest = sys.getMemory()
log.info("wifi connected " .. status.ssid .. " free=" .. free .. " largest=" .. largest)
buildWifi()
elseif status.state == "failed" or status.state == "not_found" then
message = "connection " .. status.state
log.info("wifi connection " .. status.state)
buildWifi()
end
return
end
if mode ~= "calibrate" then return end
local rx, ry = input.getRawTouch()
if not armed then
if not rx then armed = true end
return
end
if rx then
pending = {x = rx, y = ry}
elseif pending then
samples[#samples + 1] = pending
pending = nil
if #samples == 1 then drawTarget(2) else finishCalibration() end
end
end