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:
2026-08-06 17:07:56 -04:00
parent e5ed980294
commit 2046938d32
9 changed files with 479 additions and 45 deletions
+174 -28
View File
@@ -12,7 +12,6 @@ local ui = {}
---@field justify? "start"|"center"|"end"|"between"
---@field row? boolean
---@field at? table
---@field capture? boolean
---@field label? string
---@field font? GuiFont
---@field style? GuiTextStyle
@@ -58,10 +57,27 @@ local laidOut = false
local themeName
-- One tree per state, built by the function ui.mount() was given. Rebuilding is
-- cheap enough that nothing is retained between screens.
local builder, root, captured, insideCaptured, confirming
local builder, root, responder, insideResponder, confirming
local inset = 0
local applyPalette
-- Scroll gestures live in the tree, not the app: a drag on any scrollX/scrollY box pans it,
-- a flick coasts, and a child button's tap is suppressed once the drag passes SLOP. C++ owns
-- only the clamped offset (setScroll/getScroll); the momentum and the tap-vs-pan decision are
-- here, keyed by node so every scrollable box gets them for free.
local SCROLL_SLOP = 8
local SCROLL_DECAY = 0.02
local SCROLL_STOP = 8
-- id -> {x=bool, y=bool}: which axes a box pans. Recorded at build, cleared on reset.
local scrollNodes = {}
-- id -> {sx, sy, vx, vy}: pan offset (float, floored into setScroll) and flick velocity.
local scrollState = {}
-- Set of ids still coasting, iterated each draw until they settle.
local flinging = {}
-- Per-gesture bookkeeping: the box being panned once past SLOP, and the raw drag deltas.
local panning, scrollAncestor, dragged
local downX, downY, lastX, lastY, pendingX, pendingY
local function mix(a, b, amount)
local out = {}
for i = 1, 3 do
@@ -202,6 +218,10 @@ local function build(spec, kind)
clickHandlers[id] = spec.on_click
painters[id] = spec.paint
pressStyles[id] = spec.press_style ~= false
if spec.scrollX or spec.scrollY then
scrollNodes[id] = { x = spec.scrollX == true, y = spec.scrollY == true }
scrollState[id] = { sx = 0, sy = 0, vx = 0, vy = 0 }
end
return id
end
@@ -362,7 +382,9 @@ function ui.confirm(spec)
at = { x = 0, y = 0 },
w = "fill",
h = "fill",
capture = true,
-- A full-bleed layer painted last: hit-testing returns the topmost node, so taps on the
-- dim area land here and cannot reach the content beneath. on_outside handles them; with
-- none, they die at the root -- which is what makes the dialog modal.
align = "center",
justify = "center",
on_click = spec.on_outside,
@@ -374,7 +396,9 @@ function ui.reset()
tree.reset()
clearState()
laidOut = false
root, captured, insideCaptured, confirming = nil, nil, nil, nil
root, responder, insideResponder, confirming = nil, nil, nil, nil
scrollNodes, scrollState, flinging = {}, {}, {}
panning, scrollAncestor, dragged = nil, nil, false
end
applyPalette = function(root)
@@ -421,17 +445,90 @@ function ui.rebuild()
collectgarbage()
end
-- Pushes a node's float offset into the tree, then snaps our copy back to whatever the
-- clamp allowed -- without this the offset runs past the end while the content sits still
-- and the list ignores the first part of the drag back.
local function applyScroll(id, st)
ui.setScroll(id, math.floor(st.sx), math.floor(st.sy))
local cx, cy = ui.getScroll(id)
if cx and cx ~= math.floor(st.sx) then
st.sx, st.vx = cx, 0
end
if cy and cy ~= math.floor(st.sy) then
st.sy, st.vy = cy, 0
end
end
-- Free settle: velocity decays to rest, applied as DECAY^seconds so the glide lasts the
-- same wall time whatever the frame rate. A paged box would instead ease toward the nearest
-- page boundary here -- same gesture, different ending -- so this is the one seam paging adds.
local function settleFree(st, seconds)
st.sx, st.sy = st.sx + st.vx * seconds, st.sy + st.vy * seconds
local keep = SCROLL_DECAY ^ seconds
st.vx, st.vy = st.vx * keep, st.vy * keep
if math.abs(st.vx) < SCROLL_STOP then
st.vx = 0
end
if math.abs(st.vy) < SCROLL_STOP then
st.vy = 0
end
end
-- One GC step a frame, because the collector's default pace is the painter's problem: a
-- band buffer needs a contiguous block, and letting the heap double before a cycle lets
-- garbage take the block the band was going to get. Stepping keeps the sawtooth shallow
-- enough that beginBuffer() keeps succeeding instead of falling back to the panel.
function ui.draw()
---@param deltaMs? integer Elapsed frame time, used to advance any active pan or flick.
function ui.draw(deltaMs)
local seconds = (deltaMs or 0) / 1000
if seconds > 0 then
if panning then
local st = scrollState[panning]
st.sx, st.sy = st.sx + pendingX, st.sy + pendingY
-- Velocity is measured over the frame the motion arrived in, so a finger that paused
-- before lifting reports zero and does not flick.
st.vx, st.vy = pendingX / seconds, pendingY / seconds
pendingX, pendingY = 0, 0
applyScroll(panning, st)
end
for id in pairs(flinging) do
local st = scrollState[id]
settleFree(st, seconds)
applyScroll(id, st)
if st.vx == 0 and st.vy == 0 then
flinging[id] = nil
end
end
end
if root then
tree.draw(root)
end
collectgarbage "step"
end
-- The nearest ancestor (or self) that pans, or nil. A drag on a child button scrolls the
-- list it sits in, which is why the tap has to yield to the pan rather than the reverse.
local function scrollableAncestor(id)
while id do
if scrollNodes[id] then
return id
end
id = tree.getParent(id)
end
end
-- The nearest ancestor (or self) that handles a press, or nil. tree.hit returns the deepest
-- node by geometry -- a button's text label, say -- so dispatch bubbles up to whoever owns
-- the on_click/on_enter/on_exit, the way a responder chain does.
local function handlerAncestor(id)
while id do
if clickHandlers[id] or enterHandlers[id] or exitHandlers[id] then
return id
end
id = tree.getParent(id)
end
end
local function inside(id, x, y)
local rx, ry, rw, rh = tree.getRect(id)
return x >= rx and x < rx + rw and y >= ry and y < ry + rh
@@ -470,29 +567,67 @@ function ui.down(x, y)
end
end
local target = root and tree.hit(root, x, y)
if not target then
local hitNode = root and tree.hit(root, x, y)
if not hitNode then
return false
end
captured, insideCaptured = target, true
enter(target, x, y)
return true
responder, scrollAncestor = handlerAncestor(hitNode), scrollableAncestor(hitNode)
insideResponder, dragged, panning = responder ~= nil, false, nil
downX, downY, lastX, lastY = x, y, x, y
pendingX, pendingY = 0, 0
if scrollAncestor then
-- A finger down catches an in-progress glide, the way every touch UI does.
flinging[scrollAncestor] = nil
local st = scrollState[scrollAncestor]
st.vx, st.vy = 0, 0
end
if responder then
enter(responder, x, y)
end
return responder ~= nil or scrollAncestor ~= nil
end
---@param x integer
---@param y integer
---@return boolean handled
function ui.move(x, y)
if not captured then
if not responder and not scrollAncestor then
return false
end
local isInside = inside(captured, x, y)
if isInside ~= insideCaptured then
insideCaptured = isInside
if isInside then
enter(captured, x, y)
else
exit(captured, x, y)
if scrollAncestor then
local flags = scrollNodes[scrollAncestor]
if flags.x then
pendingX = pendingX + (lastX - x)
end
if flags.y then
pendingY = pendingY + (lastY - y)
end
lastX, lastY = x, y
if not dragged then
local past = (flags.x and math.abs(x - downX) > SCROLL_SLOP)
or (flags.y and math.abs(y - downY) > SCROLL_SLOP)
if past then
dragged, panning = true, scrollAncestor
-- The press became a scroll: drop the responder's feedback and keep it from clicking.
if responder and insideResponder then
exit(responder, x, y)
end
insideResponder = false
end
end
if dragged then
return true
end
end
if responder then
local isInside = inside(responder, x, y)
if isInside ~= insideResponder then
insideResponder = isInside
if isInside then
enter(responder, x, y)
else
exit(responder, x, y)
end
end
end
return true
@@ -502,19 +637,30 @@ end
---@param y integer
---@return boolean handled
function ui.up(x, y)
local target = captured
if not target then
if not responder and not scrollAncestor then
return false
end
local wasActive = insideCaptured
local releasedInside = inside(target, x, y)
local handler = releasedInside and clickHandlers[target] or nil
captured, insideCaptured = nil, nil
if wasActive then
exit(target, x, y)
if dragged then
-- The gesture was a pan: hand any velocity the last frames built to draw(), which coasts
-- and clamps it. A drag that ended still carries zero velocity, so it simply stops.
if panning then
flinging[panning] = true
end
responder, insideResponder, scrollAncestor, dragged, panning = nil, nil, nil, false, nil
return true
end
if handler then
handler(target, x, y)
local target, wasActive = responder, insideResponder
responder, insideResponder, scrollAncestor = nil, nil, nil
if target then
if wasActive then
exit(target, x, y)
end
if inside(target, x, y) then
local handler = clickHandlers[target]
if handler then
handler(target, x, y)
end
end
end
return true
end
+275
View File
@@ -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