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