01dfc4b458
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.
420 lines
13 KiB
Lua
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
|