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