Files
slate32/sdcard/apps/Settings/main.lua
T
evan 01dfc4b458 refactor: capitalize app directory names
The directory name is what the status bar and the launcher cards display, so Home,
Hello and Settings read as titles without a lookup table. Updates the firmware's home
path, the launcher's self-exclusion and the host tests that load the settings app.

Also brings the bar's next repaint forward when an app renames itself or the frame
rotates. The bar still decides what changed; this only stops the answer waiting most of
a second for the next tick.
2026-08-02 13:20:19 -04:00

420 lines
13 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 keyboardPage = "lower"
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)
message = ok and "timezone saved" or "save failed"
buildMenu()
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, {size = 2, fit = side - 12}),
}
if value then spec[#spec + 1] = ui.label(value, {fit = side - 12}) end
return ui.button(spec)
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(6, 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),
card(side, "exit", nil, sys.exit),
}
-- 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"}
for index = 1, #cards, cols do
local row = {row = true, gap = MENU_GAP}
for column = index, math.min(index + cols - 1, #cards) do row[#row + 1] = cards[column] end
row.w = #row * side + (#row - 1) * MENU_GAP
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
local function selectNetwork(button)
chooseNetwork(button.network)
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 ""
items[#items + 1] = ui.button{
label = network.ssid .. lock .. " " .. network.rssi,
network = network,
on_press = selectNetwork,
}
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()
passwordLabel:setText("password: " .. password)
passwordRow:invalidate()
end
local KEY_W, KEY_H, KEY_GAP, KEY_ROW_GAP = 24, 28, 4, 5
local KEY_CONTROLS = {
{label = "page", action = "page", w = 48},
{label = "backspace", action = "backspace", w = 68},
{label = "space", action = "space", w = 40},
{label = "connect", action = "connect", w = 60},
{label = "cancel", action = "cancel", w = 52},
}
local function drawKey(node, label, x, y, w)
local gradient = node.gradient
local top, bottom = gradient[1], gradient[2]
gui.roundRect(x, y, w, KEY_H, node.radius, node.bg, top, bottom, node.color)
gui.drawText(label, x + math.floor((w - gui.getTextWidth(label)) / 2),
y + math.floor((KEY_H - gui.getFontHeight()) / 2), node.color, bottom)
end
local function paintKeyboard(node)
local y = node.rect.y
for _, chars in ipairs(node.keyRows) do
local x = node.rect.x
for char in chars:gmatch(".") do
drawKey(node, char, x, y, KEY_W)
x = x + KEY_W + KEY_GAP
end
y = y + KEY_H + KEY_ROW_GAP
end
local x = node.rect.x
for _, control in ipairs(KEY_CONTROLS) do
drawKey(node, control.action == "page" and keyboardPage or control.label, x, y, control.w)
x = x + control.w + KEY_GAP
end
end
local function pressKeyboard(node, x, y)
local row = math.floor((y - node.rect.y) / (KEY_H + KEY_ROW_GAP)) + 1
if (y - node.rect.y) % (KEY_H + KEY_ROW_GAP) >= KEY_H then return end
if row <= #node.keyRows then
local offset = x - node.rect.x
local column = math.floor(offset / (KEY_W + KEY_GAP)) + 1
if offset % (KEY_W + KEY_GAP) >= KEY_W then return end
local char = node.keyRows[row]:sub(column, column)
if char ~= "" and #password < 64 then password = password .. char; updatePassword() end
return
end
if row ~= #node.keyRows + 1 then return end
local offset = x - node.rect.x
for _, control in ipairs(KEY_CONTROLS) do
if offset < control.w then
if control.action == "page" then
keyboardPage = keyboardPage == "lower" and "upper" or
(keyboardPage == "upper" and "symbols" or "lower")
keyboardRequested = true
elseif control.action == "backspace" then
password = password:sub(1, -2)
updatePassword()
elseif control.action == "space" and #password < 64 then
password = password .. " "
updatePassword()
elseif control.action == "connect" then
connectSelected()
elseif control.action == "cancel" then
buildWifi()
end
return
end
offset = offset - control.w - KEY_GAP
end
end
function buildKeyboard()
screen = nil
collectgarbage()
mode = "password"
local rows
if keyboardPage == "symbols" then
rows = {"!@#$%^&*()", "-_=+[]{}", "`~;:'\",.?", "/\\|<>"}
else
rows = {"1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm,.?"}
if keyboardPage == "upper" then
for i, row in ipairs(rows) do rows[i] = row:upper() end
end
end
passwordLabel = ui.text("password: " .. password)
passwordRow = ui.box{passwordLabel}
themed{pad = 8, gap = KEY_ROW_GAP,
ui.text(selectedNetwork.ssid), passwordRow,
ui.box{h = 5 * KEY_H + 4 * KEY_ROW_GAP, keyRows = rows, press_style = false,
paint = paintKeyboard, on_press = pressKeyboard},
}
log.info("wifi keyboard ready")
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
buildWifi()
elseif status.state == "failed" or status.state == "not_found" then
message = "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