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 = {"[]{}#%^*+=", "_\\|~<>$&@", ".,?!'"}, } -- Keyed by node id, because the node itself is sixteen bytes in the firmware and holds -- nothing a keyboard cares about. local state = {} 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(rect, width) return rect.x + (rect.w - width) // 2 end local function thirdRow(page, rect) local value = chars(page, 3) local width = SIDE_W * 2 + KEY_GAP * 2 + rowWidth(#value) return value, centeredX(rect, width) end -- Pure geometry: which key covers a point, given a page and the rectangle the board was -- placed in. Kept free of the node tree so the host tests can exercise the maths that -- actually decides what a tap enters. function M.keyAt(page, rect, x, y) local localY = y - 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(page, row) local localX = x - centeredX(rect, 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(page, rect) local localX = x - start if localX < 0 then return end if localX < SIDE_W then return 90, (page == "lower" or 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(rect, 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(id, label, x, y, width, pressed) local theme = ui.theme local gradient = pressed and theme.face_pressed or theme.face local color = pressed and theme.accent_fg or theme.fg gui.roundRect(x, y, width, KEY_H, theme.radius, theme.bg, gradient[1], gradient[2], color) if label == "shift" then drawArrow(x, y, width, color, state[id].page == "upper") else gui.drawText(label, x + (width - gui.getTextWidth(label)) // 2, y + (KEY_H - gui.getFontHeight()) // 2, color, gradient[2]) end end -- Walks every key, handing each one to `visit`. Painting the whole board and repainting a -- single key are the same traversal, so a key's position is defined in one place. Pure, -- for the same reason keyAt is. function M.eachKey(page, rect, visit) local y = rect.y for row = 1, 2 do local value = chars(page, row) local x = centeredX(rect, rowWidth(#value)) for column = 1, #value do visit((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(page, rect) visit(90, (page == "lower" or page == "upper") and "shift" or (page == "numbers" and "#+=" or "123"), x, y, SIDE_W) x = x + SIDE_W + KEY_GAP for column = 1, #value do visit(20 + column, value:sub(column, column), x, y, KEY_W) x = x + KEY_W + KEY_GAP end visit(91, "<-", x, y, SIDE_W) y = y + KEY_H + ROW_GAP local width = MODE_W + SPACE_W + OK_W + KEY_GAP * 2 x = centeredX(rect, width) visit(100, (page == "lower" or page == "upper") and "123" or "ABC", x, y, MODE_W) x = x + MODE_W + KEY_GAP visit(101, "space", x, y, SPACE_W) x = x + SPACE_W + KEY_GAP visit(102, "OK", x, y, OK_W) end local function rectOf(id) local x, y, w, h = node.getRect(id) return {x = x, y = y, w = w, h = h} end local function paint(id) M.eachKey(state[id].page, rectOf(id), function(key, label, x, y, width) drawKey(id, label, x, y, width, false) end) end -- Repaints one key in place. The keyboard is a single node, so there is no parent to -- clear and nothing else on screen can have moved. local function drawOneKey(id, target, pressed) M.eachKey(state[id].page, rectOf(id), function(key, label, x, y, width) if key == target then drawKey(id, label, x, y, width, pressed) end end) end local function down(id, x, y) local key = M.keyAt(state[id].page, rectOf(id), x, y) state[id].activeKey = key if key then drawOneKey(id, key, true) end end local function unpress(id) local key = state[id].activeKey state[id].activeKey = nil if key then drawOneKey(id, key, false) end end local function press(id, x, y) local st = state[id] local key, action, char = M.keyAt(st.page, rectOf(id), x, y) if not key or key ~= st.activeKey then return end local function changed() if st.on_change then st.on_change(st.value) end end if action == "char" then if #st.value < st.max_length then st.value = st.value .. char; changed() end elseif action == "shift" then st.page = st.page == "lower" and "upper" or "lower" node.invalidate(id) elseif action == "symbols" then st.page = st.page == "numbers" and "symbols" or "numbers" node.invalidate(id) elseif action == "mode" then st.page = (st.page == "lower" or st.page == "upper") and "numbers" or "lower" node.invalidate(id) elseif action == "backspace" then st.value = st.value:sub(1, -2) changed() elseif action == "space" then if #st.value < st.max_length then st.value = st.value .. " "; changed() end elseif action == "submit" and st.on_submit then st.on_submit(st.value) end end -- One custom-painted node keeps a full keyboard off the heap: ordinary ui.button keys -- would add dozens of nodes and their styles while wifi is already holding buffers. function M.new(spec) spec = spec or {} local id = ui.custom{ h = 4 * KEY_H + 3 * ROW_GAP, paint = paint, on_down = down, on_press = press, on_unpress = unpress, press_style = false, -- a key highlights itself; the node never does } state[id] = { value = spec.value or "", max_length = spec.max_length or 64, page = "lower", on_change = spec.on_change, on_submit = spec.on_submit, } return id end return M