Files
slate32/sdcard/lib/keyboard.lua
T
evan f7c5cc09ba feat(ui)!: move the widget tree into C++
A node was a Lua table of ~625 bytes, of which 21 keys pushed it over a
power-of-two hash boundary and eight were style copies inheritance had
splattered down from its parent. A 400 node screen cost ~250 KB and could not
coexist with wifi's buffers.

The tree now lives in src/ui/layout.h as a 16 byte struct in a flat arena, and
splits by lifetime: Node holds what hit testing and repainting need forever,
Spec holds what only measure/place read and is dropped when layout ends. Style
is sparse and resolved by walking parents, so a node naming no colours costs
nothing. Re-layout rebuilds from Lua rather than retaining the inputs.

    401 nodes:  8218 B steady, 21050 B peak
           Lua: ~250000 B steady

sdcard/lib/ui.lua stays the toolkit and keeps every constructor signature, but
returns integer handles: 627 lines to 374. Composition, the palette and custom
painters are still Lua on the SD card; only primitives now need a reflash.

BREAKING CHANGE: ui constructors return handles, not tables. Use
ui.setText(id, text) and keep per-node app data in a table keyed by id.
2026-08-02 18:48:48 -04:00

233 lines
7.4 KiB
Lua

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