b9b620ad2b
Leaving an app was the app's own responsibility, so one that shipped without an exit could only be escaped with a reset. The bar now paints a back button into its leading square and the firmware treats that rect as home, acting on release so a press sliding into the app cancels. The app it returns to is /apps/home, which is what it is to the user. Card grids in home and settings centre left to right as a unit.
410 lines
13 KiB
Lua
410 lines
13 KiB
Lua
local ui = require("ui")
|
|
|
|
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 = sys.setCalibration(computeCalibration(samples[1], samples[2], 320, 480, INSET))
|
|
message = ok and "calibration saved" or "save failed"
|
|
mode = "menu"
|
|
gui.setRotation(sys.getRotation() / 90)
|
|
gui.fullscreen(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.
|
|
gui.fullscreen(true)
|
|
drawTarget(1)
|
|
end
|
|
|
|
function cycleRotation()
|
|
local ok = sys.setRotation((sys.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 = sys.setTheme(names[next_index])
|
|
ui.reloadTheme()
|
|
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 = sys.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 = sys.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 = sys.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", sys.getRotation() .. " deg", cycleRotation),
|
|
card(side, "wifi", wifiValue(wifi.status()), 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
|
|
|
|
local function requestScan()
|
|
mode = "scanning"
|
|
scanRequested = true
|
|
themed{pad = 12, gap = 8, ui.text("wifi"), ui.text("scanning...")}
|
|
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.status().ssid,
|
|
ok = "forget",
|
|
on_ok = forgetNetwork,
|
|
on_cancel = function()
|
|
dialog = nil
|
|
buildWifi()
|
|
end,
|
|
}
|
|
buildWifi()
|
|
end
|
|
|
|
function buildWifi()
|
|
mode = "wifi"
|
|
local status = wifi.status()
|
|
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"
|
|
themed{pad = 12, ui.text("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.textWidth(label)) / 2),
|
|
y + math.floor((KEY_H - gui.fontHeight()) / 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.status()
|
|
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
|