feat(ui): tree-owned scroll gestures and geometry hit-testing
Drag, flick and tap-vs-scroll now live on the scrollX/scrollY flag in ui.lua, so any scroll box pans with no app code. tree.hit returns the deepest node by geometry and dispatch bubbles to the nearest handler, dropping the now-unused CAPTURE flag. keyboard moves in as ui.keyboard, and embed compiles nested lib dirs to dotted module names.
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
local ui = require "ui"
|
||||
local FONT = screen.FONT_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
|
||||
screen.drawLine(middle, y + 8, middle, y + 20, color)
|
||||
screen.drawLine(middle - 5, y + 15, middle, y + 20, color)
|
||||
screen.drawLine(middle, y + 20, middle + 5, y + 15, color)
|
||||
else
|
||||
screen.drawLine(middle, y + 9, middle, y + 21, color)
|
||||
screen.drawLine(middle - 5, y + 14, middle, y + 9, color)
|
||||
screen.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 face = pressed and theme.pressedFace or theme.face
|
||||
local color = pressed and theme.pressedColor or theme.color
|
||||
screen.roundRect(x, y, width, KEY_H, theme.radius, theme.background, face, nil, color)
|
||||
if label == "shift" then
|
||||
drawArrow(x, y, width, color, state[id].page == "upper")
|
||||
else
|
||||
screen.drawText(
|
||||
FONT,
|
||||
x + (width - screen.getTextWidth(FONT, label)) // 2,
|
||||
y + (KEY_H - screen.getFontHeight(FONT)) // 2,
|
||||
label,
|
||||
color,
|
||||
nil,
|
||||
face
|
||||
)
|
||||
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 = tree.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
|
||||
|
||||
-- The release repaints the key but keeps activeKey, because ui fires on_exit before
|
||||
-- on_click and the click still has to know which key the press started on.
|
||||
local function unpress(id)
|
||||
local key = state[id].activeKey
|
||||
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
|
||||
st.activeKey = nil
|
||||
|
||||
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"
|
||||
tree.invalidate(id)
|
||||
elseif action == "symbols" then
|
||||
st.page = st.page == "numbers" and "symbols" or "numbers"
|
||||
tree.invalidate(id)
|
||||
elseif action == "mode" then
|
||||
st.page = (st.page == "lower" or st.page == "upper") and "numbers" or "lower"
|
||||
tree.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_enter = down,
|
||||
on_click = press,
|
||||
on_exit = 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
|
||||
Reference in New Issue
Block a user