From 6b6280b72a851eb4c98d3ab03fda7bf006461ebf Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Sun, 2 Aug 2026 16:07:10 -0400 Subject: [PATCH] feat(ui): add reusable on-screen keyboard --- .pi/skills/test-e32r40t-firmware/SKILL.md | 8 +- AGENTS.md | 5 + Makefile | 2 +- README.md | 15 +- sdcard/apps/Keyboard/main.lua | 25 +++ sdcard/apps/Settings/main.lua | 115 +++---------- sdcard/lib/keyboard.lua | 188 ++++++++++++++++++++++ sdcard/lib/ui.lua | 6 +- src/lua/bindings/sys.cpp | 4 +- stubs/slate32.lua | 1 + test/fake_device.lua | 3 +- test/keyboard.lua | 88 ++++++++++ test/settings_calibration.lua | 13 +- test/ui_layout.lua | 6 + 14 files changed, 377 insertions(+), 102 deletions(-) create mode 100644 sdcard/apps/Keyboard/main.lua create mode 100644 sdcard/lib/keyboard.lua create mode 100644 test/keyboard.lua diff --git a/.pi/skills/test-e32r40t-firmware/SKILL.md b/.pi/skills/test-e32r40t-firmware/SKILL.md index 92122ae..1edc58e 100644 --- a/.pi/skills/test-e32r40t-firmware/SKILL.md +++ b/.pi/skills/test-e32r40t-firmware/SKILL.md @@ -84,9 +84,11 @@ before the first one. Use `touch-hold` plus `capture` plus `touch-release` to photograph a pressed button: `on_press` fires on release, and the pressed style is only visible mid-gesture. -Wi-Fi testing uses the emulator's single open `qemu` AP. In Settings, scan and tap -`qemu`; success shows DHCP address `192.168.4.15`. The AP uses the same QEMU -user-mode NAT backend as Xteink, so it has outbound internet access. +Wi-Fi testing exposes open `qemu` and secured `qemu2` (`qemuqemu`). A scan can miss one +beacon; rescan until both appear when testing the password keyboard. The current emulator +advertises `qemu2` but rejects its secure association in `WiFiSTA::begin()` (`config failed`), +so use it to verify password entry/submission and use open `qemu` for a connected assertion. +A successful `qemu` join gets `192.168.4.15` through QEMU user-mode NAT. When the firmware runs rotated, convert the UI position `(sx, sy)` to the physical tap: diff --git a/AGENTS.md b/AGENTS.md index c6d1118..49bf795 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,11 @@ arguments, never Lua states. Apps receive the string as `init(arg)` -- states sh memory, so a string is the whole handoff. `sys.setAppName()` retitles the bar for a screen within an app. +`sdcard/lib/keyboard.lua` deliberately paints all keys in one node; ordinary `ui.button` +keys add dozens of tables and exhaust heap while WiFi is active. `Screen:down()` calls a +node's optional `on_down(node, x, y)` so the keyboard can highlight one sub-key and require +release over that same key. + ## Status Bar `sdcard/lib/statusbar.lua` paints the top strip; the firmware only clips apps out of it and diff --git a/Makefile b/Makefile index 9dbbc60..bb1296f 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ LUA ?= lua CXX ?= c++ CXXFLAGS ?= -std=c++11 -Wall -Wextra BUILD_DIR := .pio/build/esp32-32e -LUA_TESTS := test/ui_layout.lua test/ui_theme.lua test/settings_calibration.lua test/statusbar_dirty.lua test/settings_busy.lua +LUA_TESTS := test/ui_layout.lua test/ui_theme.lua test/keyboard.lua test/settings_calibration.lua test/statusbar_dirty.lua test/settings_busy.lua .PHONY: test test-lua test-cpp test-stubs stubs build upload monitor clean diff --git a/README.md b/README.md index b5e573d..f8a659b 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ it calls `sys.setAppName("settings - wifi")`, which the status bar picks up on i | `gui` | `getWidth()`, `getHeight()`, `clear(color)`, `fillRect(x,y,w,h,c)`, `drawRect(x,y,w,h,c)`, `fillCircle(x,y,r,c,bg)`, `drawLine(x1,y1,x2,y2,c)`, `drawText(text,x,y,fg,bg)`, `roundRect(x,y,w,h,radius,bg,top,bottom,border)`, `getFontHeight()`, `getTextWidth(text)`, `getRotation()`, `setRotation(deg)`, `setFullscreen(on)`, `color(r,g,b)` | | `input` | `getTouch()` -> `x,y` or nil, `getRawTouch()` -> raw ADC `x,y` or nil, `isTouched()` | | `fs` | `readFile(path)`, `writeFile(path, data)`, `exists(path)`, `listFiles(path)`, `listDirs(path)` | -| `sys` | `getMillis()`, `delay(ms)`, `back()`, `launch(path, arg)`, `replace(path, arg)`, `getAppName()`, `setAppName(name)`, `getMemory()` -> `free,total`, `isClockSynced()`, `setTickInterval(ms)` | +| `sys` | `getMillis()`, `delay(ms)`, `back()`, `launch(path, arg)`, `replace(path, arg)`, `getAppName()`, `setAppName(name)`, `getMemory()` -> `free,total,largest`, `isClockSynced()`, `setTickInterval(ms)` | | `settings` | `getRotation()`, `setRotation(deg)`, `getTheme()`, `setTheme(name)`, `getTimezone()`, `setTimezone(tz)`, `setCalibration(x0,y0,x1,y1)` | | `wifi` | `scan()` -> `{ssid,rssi,secure}[]`, `connect(ssid,password)`, `getStatus()` -> `{state,ssid,ip,rssi}`, `getLocalIP()`, `isConnected()`, `disconnect()`, `forget()` | | `log` | `debug(msg)`, `info(msg)`, `error(msg)` (serial) | @@ -188,6 +188,19 @@ duration, touch slop, and per-component dirty tracking. Any table with `measure` Not implemented: scrolling, so a list longer than the screen is unreachable. +`/lib/keyboard.lua` provides a single-node, staggered QWERTY keyboard so its keys do not +retain dozens of component tables. It owns shift, number/symbol pages, backspace, per-key +pressed feedback, and release-over-the-same-key activation: + +```lua +keyboard.new{ + value = password, + max_length = 64, + on_change = function(value) password = value end, + on_submit = connect, +} +``` + ## Themes (`/lib/theme.lua`) A theme is three seed colors; `ui.lua` derives the rest, so adding a component never diff --git a/sdcard/apps/Keyboard/main.lua b/sdcard/apps/Keyboard/main.lua new file mode 100644 index 0000000..02c063a --- /dev/null +++ b/sdcard/apps/Keyboard/main.lua @@ -0,0 +1,25 @@ +local ui = require("ui") +local keyboard = require("keyboard") + +local screen, valueLabel + +local function show(prefix, value) + valueLabel:setText(value == "" and "type something" or prefix .. value) +end + +function init() + valueLabel = ui.text("type something", {w = "fill"}) + screen = ui.screen(ui.box{pad = 8, gap = 5, + valueLabel, + keyboard.new{ + max_length = 40, + on_change = function(value) show("value: ", value) end, + on_submit = function(value) show("submitted: ", value) end, + }, + }) + log.info("keyboard test ready") +end + +function draw() screen:draw() end +function on_touch_down(x, y) screen:down(x, y) end +function on_touch_up(x, y) screen:up(x, y) end diff --git a/sdcard/apps/Settings/main.lua b/sdcard/apps/Settings/main.lua index a748339..7476b6b 100644 --- a/sdcard/apps/Settings/main.lua +++ b/sdcard/apps/Settings/main.lua @@ -11,7 +11,7 @@ local mode = "menu" local samples, pending, armed = {}, nil, false local scanRequested, keyboardRequested = false, false local selectedNetwork, password = nil, "" -local keyboardPage = "lower" +local timezoneCard local buildMenu, buildWifi, buildNetworks, buildKeyboard, startCalibration, cycleRotation, updatePassword @@ -108,8 +108,8 @@ local function cycleTimezone() 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() + timezoneCard.valueLabel:setText(ok and zoneLabel() or "save failed") + timezoneCard:invalidate() end local function wifiValue(status) @@ -125,8 +125,14 @@ local function card(side, title, value, on_press) on_press = on_press, ui.label(title, {fit = side - 12}), } - if value then spec[#spec + 1] = ui.label(value, {fit = side - 12}) end - return ui.button(spec) + 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 button.valueLabel = valueLabel end + return button end function buildMenu() @@ -141,6 +147,7 @@ function buildMenu() 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"} @@ -263,102 +270,29 @@ function buildNetworks(networks) themed(items) end -function updatePassword() +function updatePassword(value) + password = value 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() + local keyboard = require("keyboard") 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, + themed{pad = 8, gap = 5, 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}, + keyboard.new{ + value = password, + on_change = updatePassword, + on_submit = function(value) password = value; connectSelected() end, + }, } - log.info("wifi keyboard ready") + local free, _, largest = sys.getMemory() + log.info("wifi keyboard ready free=" .. free .. " largest=" .. largest) end function init() @@ -394,9 +328,12 @@ function on_tick() 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 diff --git a/sdcard/lib/keyboard.lua b/sdcard/lib/keyboard.lua new file mode 100644 index 0000000..00dc777 --- /dev/null +++ b/sdcard/lib/keyboard.lua @@ -0,0 +1,188 @@ +local ui = require("ui") + +local M = {} + +local KEY_W, KEY_H, KEY_GAP, ROW_GAP = 26, 30, 4, 5 +local SIDE_W, MODE_W, SPACE_W, OK_W = 40, 54, 174, 58 +local PAGES = { + lower = {"qwertyuiop", "asdfghjkl", "zxcvbnm"}, + upper = {"QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM"}, + numbers = {"1234567890", "-/:;()$&@", ".,?!'"}, + symbols = {"[]{}#%^*+=", "_\\|~<>$&@", ".,?!'"}, +} + +local function chars(page, row) + return PAGES[page][row] +end + +local function rowWidth(count) + return count * KEY_W + (count - 1) * KEY_GAP +end + +local function centeredX(node, width) + return node.rect.x + (node.rect.w - width) // 2 +end + +local function thirdRow(node) + local value = chars(node.page, 3) + local width = SIDE_W * 2 + KEY_GAP * 2 + rowWidth(#value) + return value, centeredX(node, width) +end + +local function keyAt(node, x, y) + local localY = y - node.rect.y + if localY < 0 then return end + local row = localY // (KEY_H + ROW_GAP) + 1 + if row > 4 or localY % (KEY_H + ROW_GAP) >= KEY_H then return end + + if row <= 2 then + local value = chars(node.page, row) + local localX = x - centeredX(node, rowWidth(#value)) + if localX < 0 then return end + local column = localX // (KEY_W + KEY_GAP) + 1 + if column <= #value and localX % (KEY_W + KEY_GAP) < KEY_W then + return (row - 1) * 10 + column, "char", value:sub(column, column) + end + return + end + + if row == 3 then + local value, start = thirdRow(node) + local localX = x - start + if localX < 0 then return end + if localX < SIDE_W then + return 90, (node.page == "lower" or node.page == "upper") and "shift" or "symbols" + end + localX = localX - SIDE_W - KEY_GAP + local width = rowWidth(#value) + if localX >= 0 and localX < width then + local column = localX // (KEY_W + KEY_GAP) + 1 + if localX % (KEY_W + KEY_GAP) < KEY_W then + return 20 + column, "char", value:sub(column, column) + end + return + end + localX = localX - width - KEY_GAP + if localX >= 0 and localX < SIDE_W then return 91, "backspace" end + return + end + + local width = MODE_W + SPACE_W + OK_W + KEY_GAP * 2 + local localX = x - centeredX(node, width) + if localX < 0 then return end + if localX < MODE_W then return 100, "mode" end + localX = localX - MODE_W - KEY_GAP + if localX >= 0 and localX < SPACE_W then return 101, "space" end + localX = localX - SPACE_W - KEY_GAP + if localX >= 0 and localX < OK_W then return 102, "submit" end +end + +local function drawArrow(x, y, width, color, down) + local middle = x + width // 2 + if down then + gui.drawLine(middle, y + 8, middle, y + 20, color) + gui.drawLine(middle - 5, y + 15, middle, y + 20, color) + gui.drawLine(middle, y + 20, middle + 5, y + 15, color) + else + gui.drawLine(middle, y + 9, middle, y + 21, color) + gui.drawLine(middle - 5, y + 14, middle, y + 9, color) + gui.drawLine(middle, y + 9, middle + 5, y + 14, color) + end +end + +local function drawKey(node, id, label, x, y, width) + local pressed = node.pressed and node.activeKey == id + local gradient = pressed and node.press_gradient or node.gradient + local color = pressed and (node.press_color or node.bg) or node.color + gui.roundRect(x, y, width, KEY_H, node.radius, node.bg, + gradient[1], gradient[2], color) + if label == "shift" then + drawArrow(x, y, width, color, node.page == "upper") + else + gui.drawText(label, x + (width - gui.getTextWidth(label)) // 2, + y + (KEY_H - gui.getFontHeight()) // 2, color, gradient[2]) + end +end + +local function paint(node) + local y = node.rect.y + for row = 1, 2 do + local value = chars(node.page, row) + local x = centeredX(node, rowWidth(#value)) + for column = 1, #value do + drawKey(node, (row - 1) * 10 + column, value:sub(column, column), x, y, KEY_W) + x = x + KEY_W + KEY_GAP + end + y = y + KEY_H + ROW_GAP + end + + local value, x = thirdRow(node) + drawKey(node, 90, (node.page == "lower" or node.page == "upper") and "shift" or + (node.page == "numbers" and "#+=" or "123"), x, y, SIDE_W) + x = x + SIDE_W + KEY_GAP + for column = 1, #value do + drawKey(node, 20 + column, value:sub(column, column), x, y, KEY_W) + x = x + KEY_W + KEY_GAP + end + drawKey(node, 91, "<-", x, y, SIDE_W) + + y = y + KEY_H + ROW_GAP + local width = MODE_W + SPACE_W + OK_W + KEY_GAP * 2 + x = centeredX(node, width) + drawKey(node, 100, (node.page == "lower" or node.page == "upper") and "123" or "ABC", x, y, MODE_W) + x = x + MODE_W + KEY_GAP + drawKey(node, 101, "space", x, y, SPACE_W) + x = x + SPACE_W + KEY_GAP + drawKey(node, 102, "OK", x, y, OK_W) +end + +local function down(node, x, y) + node.activeKey = keyAt(node, x, y) +end + +local function changed(node) + if node.on_change then node.on_change(node.value) end +end + +local function press(node, x, y) + local id, action, char = keyAt(node, x, y) + if not id or id ~= node.activeKey then return end + + if action == "char" then + if #node.value < node.max_length then node.value = node.value .. char; changed(node) end + elseif action == "shift" then + node.page = node.page == "lower" and "upper" or "lower" + node:invalidate() + elseif action == "symbols" then + node.page = node.page == "numbers" and "symbols" or "numbers" + node:invalidate() + elseif action == "mode" then + node.page = (node.page == "lower" or node.page == "upper") and "numbers" or "lower" + node:invalidate() + elseif action == "backspace" then + node.value = node.value:sub(1, -2) + changed(node) + elseif action == "space" then + if #node.value < node.max_length then node.value = node.value .. " "; changed(node) end + elseif action == "submit" and node.on_submit then + node.on_submit(node.value) + end +end + +-- One custom-painted node keeps a full keyboard from retaining dozens of component tables. +function M.new(spec) + spec = spec or {} + return ui.box{ + h = 4 * KEY_H + 3 * ROW_GAP, + value = spec.value or "", + max_length = spec.max_length or 64, + page = "lower", + on_change = spec.on_change, + on_submit = spec.on_submit, + paint = paint, + on_down = down, + on_press = press, + } +end + +return M diff --git a/sdcard/lib/ui.lua b/sdcard/lib/ui.lua index 5350225..7aa783c 100644 --- a/sdcard/lib/ui.lua +++ b/sdcard/lib/ui.lua @@ -334,7 +334,10 @@ end local function paintText(self) gui.setTextSize(self.size or 1) - gui.drawText(self.label, self.rect.x, self.rect.y, self.color, self.bg) + local x = self.rect.x + if self.text_align == "center" then x = x + (self.rect.w - gui.getTextWidth(self.label)) // 2 + elseif self.text_align == "end" then x = x + self.rect.w - gui.getTextWidth(self.label) end + gui.drawText(self.label, x, self.rect.y, self.color, self.bg) end local function setText(self, text) @@ -493,6 +496,7 @@ function Screen:down(x, y) if not target then return end self.captured = target self.pressedAt = sys.getMillis() + if target.on_down then target.on_down(target, x, y) end if target.press_style ~= false then target.pressed = true target:invalidate() diff --git a/src/lua/bindings/sys.cpp b/src/lua/bindings/sys.cpp index d261eb2..3b98670 100644 --- a/src/lua/bindings/sys.cpp +++ b/src/lua/bindings/sys.cpp @@ -55,7 +55,8 @@ static int l_sys_clockSynced(lua_State* L) { static int l_sys_memory(lua_State* L) { lua_pushinteger(L, ESP.getFreeHeap()); lua_pushinteger(L, ESP.getHeapSize()); - return 2; + lua_pushinteger(L, ESP.getMaxAllocHeap()); + return 3; } static int logAt(lua_State* L, const char* level) { @@ -124,6 +125,7 @@ void registerSys(lua_State* L) { // --- Free and total heap, in bytes. // @return integer Free bytes. // @return integer Total bytes. + // @return integer Largest contiguous free block. {"getMemory", l_sys_memory}, // --- Whether SNTP has answered. Until it has, os.time() is only a build-time floor. // @return boolean diff --git a/stubs/slate32.lua b/stubs/slate32.lua index ba66953..930159c 100644 --- a/stubs/slate32.lua +++ b/stubs/slate32.lua @@ -295,6 +295,7 @@ function sys.setAppName(name) end --- Free and total heap, in bytes. ---@return integer Free bytes. ---@return integer Total bytes. +---@return integer Largest contiguous free block. function sys.getMemory() end --- Whether SNTP has answered. Until it has, os.time() is only a build-time floor. diff --git a/test/fake_device.lua b/test/fake_device.lua index 9e8b7d2..f1e60ee 100644 --- a/test/fake_device.lua +++ b/test/fake_device.lua @@ -25,6 +25,7 @@ local device = { calibration = nil, -- last settings.setCalibration() freeHeap = 200000, totalHeap = 320000, + largestBlock = 100000, connected = nil, -- last wifi.connect() backed = false, launched = nil, -- last sys.launch()/replace(), {path, arg, replace} @@ -65,7 +66,7 @@ function device.install() back = function() device.backed = true end, getAppName = function() return device.appName end, setAppName = function(name) device.appName = name end, - getMemory = function() return device.freeHeap, device.totalHeap end, + getMemory = function() return device.freeHeap, device.totalHeap, device.largestBlock end, isClockSynced = function() return device.clockSynced end, setTickInterval = function(ms) assert(ms == 0 or on_tick, "setTickInterval without on_tick") diff --git a/test/keyboard.lua b/test/keyboard.lua new file mode 100644 index 0000000..38ac09f --- /dev/null +++ b/test/keyboard.lua @@ -0,0 +1,88 @@ +-- Run: lua test/keyboard.lua +package.path = "sdcard/lib/?.lua;test/?.lua;" .. package.path + +local device = require("fake_device").install() +local ui = require("ui") +local keyboard = require("keyboard") + +local changes, submitted = {}, nil +local board = keyboard.new{ + on_change = function(value) changes[#changes + 1] = value end, + on_submit = function(value) submitted = value end, +} +local screen = ui.screen(ui.box{pad = 8, board}) + +local rounds = {} +gui.roundRect = function(x, y, w, h, radius, bg, top, bottom, border) + rounds[#rounds + 1] = {x = x, y = y, w = w, top = top, bottom = bottom, border = border} +end + +local function draw() + rounds = {} + screen:draw() +end + +local function tap(x, y) + screen:down(x, y) + screen:up(x, y) + device.now = device.now + 100 + screen:draw() + screen:draw() +end + +local row1X = board.rect.x + (board.rect.w - 296) // 2 + 13 +local row2X = board.rect.x + (board.rect.w - 266) // 2 + 13 +local row3Y = board.rect.y + 85 +local bottomY = board.rect.y + 120 +local qx, qy = row1X, board.rect.y + 15 + +-- Rows are staggered and centered like a physical keyboard. +draw() +assert(rounds[1].x == board.rect.x + 4, "top row was not centered") +assert(rounds[11].x == board.rect.x + 19, "home row was not centered") + +-- Only the touched key uses the pressed palette. +local normalQ = rounds[1] +screen:down(qx, qy) +draw() +assert(rounds[1].top ~= normalQ.top or rounds[1].bottom ~= normalQ.bottom, + "pressed q kept its normal gradient") +screen:up(qx, qy) +assert(changes[#changes] == "q", "q was not entered") + +-- Sliding onto another key cancels instead of entering the release position. +screen:draw() +screen:draw() +screen:down(qx, qy) +screen:up(qx + 30, qy) +assert(#changes == 1, "sliding from q to w entered a key") + +-- Shift toggles case without replacing the keyboard. +local shiftX = board.rect.x + (board.rect.w - 294) // 2 + 20 +tap(shiftX, row3Y) +tap(qx, qy) +assert(changes[#changes] == "qQ", "shift did not enter uppercase Q") +tap(shiftX, row3Y) + +-- 123 opens numbers, #+= opens symbols, and backspace preserves the value. +local modeX = board.rect.x + (board.rect.w - 294) // 2 + 27 +tap(modeX, bottomY) +tap(qx, qy) +assert(changes[#changes] == "qQ1", "number page did not enter 1") +local symbolX = board.rect.x + (board.rect.w - 234) // 2 + 20 +tap(symbolX, row3Y) +tap(qx, qy) +assert(changes[#changes] == "qQ1[", "symbol page did not enter [") +local backspaceX = board.rect.x + (board.rect.w - 234) // 2 + 214 +tap(backspaceX, row3Y) +assert(changes[#changes] == "qQ1", "backspace did not remove the symbol") + +-- Bottom row is ABC, a wide spacebar, then OK. +tap(modeX, bottomY) +local spaceX = board.rect.x + (board.rect.w - 294) // 2 + 145 +tap(spaceX, bottomY) +local okX = board.rect.x + (board.rect.w - 294) // 2 + 265 +tap(okX, bottomY) +assert(submitted == "qQ1 ", "OK lost the keyboard value") + +print("ok") diff --git a/test/settings_calibration.lua b/test/settings_calibration.lua index f6a344a..07e8beb 100644 --- a/test/settings_calibration.lua +++ b/test/settings_calibration.lua @@ -57,6 +57,11 @@ local zones = require("timezones") init() tapRow("Timezone") assert(settings.getTimezone() == zones[2].tz, "timezone " .. settings.getTimezone()) +device.painted = {} +draw() +assert(#device.painted == 2, "timezone repainted " .. #device.painted .. " labels instead of its card") +assert(device.painted[1].label == "Timezone" and device.painted[2].label == zones[2].name, + "timezone card did not repaint its new value") tapRow("Timezone") assert(settings.getTimezone() == zones[3].tz, "timezone " .. settings.getTimezone()) @@ -101,10 +106,8 @@ tapRow("scan networks") on_tick() tapRow("secure") on_tick() -tapRow("q") -- keyboard key -tapRow("connect") -assert(#device.painted == 1, - "typing rebuilt the keyboard instead of repainting the password") -assert(device.connected[1] == "secure" and device.connected[2] == "q", "secure wifi password") +for key in ("qemuqemu"):gmatch(".") do tapRow(key) end +tapRow("OK") +assert(device.connected[1] == "secure" and device.connected[2] == "qemuqemu", "secure wifi password") print("ok") diff --git a/test/ui_layout.lua b/test/ui_layout.lua index 182470c..64ea331 100644 --- a/test/ui_layout.lua +++ b/test/ui_layout.lua @@ -27,6 +27,12 @@ screen:draw() assert(#device.painted == 1 and device.painted[1].label == "a longer label", "changing a button label repainted other components") +local centred = ui.text("go", {w = 40, text_align = "center"}) +local centredScreen = ui.screen(ui.box{centred}) +device.painted = {} +centredScreen:draw() +assert(device.painted[1].x == 14, "centred text painted at " .. device.painted[1].x) + -- Fractions resolve against the parent content box, absolutes stay absolute. local half = ui.box{w = 0.5, h = 40} local fixed = ui.box{w = 100, h = 40}