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:
@@ -16,7 +16,6 @@
|
||||
---@field justify? "start"|"center"|"end"|"between"
|
||||
---@field row? boolean
|
||||
---@field at? table Absolute-position fields.
|
||||
---@field capture? boolean
|
||||
---@field interactive? boolean
|
||||
---@field label? string
|
||||
---@field font? ScreenFont
|
||||
|
||||
+174
-28
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -92,6 +92,9 @@ tree = {
|
||||
attach = function(parent, child)
|
||||
nodes[child].parent = parent
|
||||
end,
|
||||
getParent = function(id)
|
||||
return nodes[id].parent
|
||||
end,
|
||||
setSize = function() end,
|
||||
setStyle = function(id, style)
|
||||
for key, value in pairs(style) do
|
||||
|
||||
@@ -32,7 +32,6 @@ enum Type : uint8_t { BOX, TEXT, BUTTON, CUSTOM };
|
||||
|
||||
enum Flag : uint8_t {
|
||||
ROW = 1 << 0, // main axis is horizontal
|
||||
CAPTURE = 1 << 1, // swallows the taps its children missed
|
||||
INTERACTIVE = 1 << 2, // has an on_press
|
||||
DIRTY = 1 << 3,
|
||||
PRESSED = 1 << 4,
|
||||
@@ -204,8 +203,10 @@ public:
|
||||
return error == nullptr;
|
||||
}
|
||||
|
||||
// Deepest interactive node wins, so a tappable child beats its tappable
|
||||
// parent. The list is singly linked, so "last match walking forward" stands
|
||||
// Deepest node by geometry wins, so a child beats its parent and a later sibling
|
||||
// beats an earlier one it overlaps. Whether that node responds to a press or a pan is
|
||||
// dispatch's business, which bubbles up from here -- hit-testing is not fused with who
|
||||
// handles the gesture. The list is singly linked, so "last match walking forward" stands
|
||||
// in for "first match walking backward"; they name the same node.
|
||||
uint16_t hit(uint16_t id, int px, int py) const {
|
||||
const Node& n = nodes[id];
|
||||
@@ -222,9 +223,7 @@ public:
|
||||
}
|
||||
if (found != NONE)
|
||||
return found;
|
||||
if (n.flags & CAPTURE)
|
||||
return id;
|
||||
return (n.flags & INTERACTIVE) ? id : NONE;
|
||||
return id;
|
||||
}
|
||||
|
||||
bool scrolls(uint16_t id) const {
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
// @lua-preamble ---@field justify? "start"|"center"|"end"|"between"
|
||||
// @lua-preamble ---@field row? boolean
|
||||
// @lua-preamble ---@field at? table Absolute-position fields.
|
||||
// @lua-preamble ---@field capture? boolean
|
||||
// @lua-preamble ---@field interactive? boolean
|
||||
// @lua-preamble ---@field label? string
|
||||
// @lua-preamble ---@field font? ScreenFont
|
||||
@@ -173,8 +172,6 @@ int create(lua_State* state) {
|
||||
uint8_t flags = 0;
|
||||
if (readFlag(state, 2, "row"))
|
||||
flags |= ui::ROW;
|
||||
if (readFlag(state, 2, "capture"))
|
||||
flags |= ui::CAPTURE;
|
||||
if (readFlag(state, 2, "interactive"))
|
||||
flags |= ui::INTERACTIVE;
|
||||
if (readFlag(state, 2, "scrollX"))
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -173,7 +173,8 @@ int main() {
|
||||
"assert(x == 4 and y == 4 and w == 312 and h == 16)\n"
|
||||
"assert(tree.getLabel(label) == 'Hello')\n"
|
||||
"assert(tree.hit(root, 10, 40) == button)\n"
|
||||
"assert(tree.hit(root, 10, 200) == nil)\n"
|
||||
// Hit is geometry: a point inside root but over no child answers root, not nil.
|
||||
"assert(tree.hit(root, 10, 200) == root)\n"
|
||||
"assert(tree.focusFirst(root) == button)\n"
|
||||
"assert(tree.getFocus() == button)\n"
|
||||
"assert(tree.moveFocus(root, 'up') == button)\n"
|
||||
@@ -227,6 +228,14 @@ int main() {
|
||||
"assert(bx == 0 and by == 0)\n"
|
||||
// Row 1 is scrolled above the box, so a tap at the top hits row 2.
|
||||
"assert(tree.hit(list, 10, 10) == rows[2], 'scrolled-out row is not hit')\n"
|
||||
// Hit is pure geometry: the deepest node covering the point, else the container. A
|
||||
// scroll pan finds its box by bubbling up from here, not by a flag on hit.
|
||||
"local bare = tree.create(nil, {type = 'box', w = 'fill', h = 'fill', scrollY = true})\n"
|
||||
"local child = tree.create(bare, {type = 'box', w = 100, h = 900})\n"
|
||||
"assert(tree.layout(bare, 0, 0, 320, 240))\n"
|
||||
"assert(tree.hit(bare, 10, 10) == child, 'deepest node by geometry wins')\n"
|
||||
"assert(tree.hit(bare, 200, 10) == bare, 'the container answers where no child covers it')\n"
|
||||
"tree.dropScratch()\n"
|
||||
// Clamped, never past the content's far edge or before its start.
|
||||
"tree.setScroll(list, 9999, 9999)\n"
|
||||
"local cx, cy = tree.getScroll(list)\n"
|
||||
|
||||
+8
-4
@@ -35,7 +35,9 @@ def main():
|
||||
lib_dir = pathlib.Path(lib_dir)
|
||||
out_path = pathlib.Path(out_path)
|
||||
|
||||
sources = sorted(lib_dir.glob("*.lua"))
|
||||
# Recurse so ui/keyboard.lua embeds as the module require("ui.keyboard") asks for: the
|
||||
# module name is the path with dots, the C array identifier the same with underscores.
|
||||
sources = sorted(lib_dir.rglob("*.lua"))
|
||||
arrays = []
|
||||
rows = []
|
||||
for src in sources:
|
||||
@@ -45,10 +47,12 @@ def main():
|
||||
subprocess.run([luac32, str(src), tmp_path], check=True)
|
||||
bytecode = pathlib.Path(tmp_path).read_bytes()
|
||||
pathlib.Path(tmp_path).unlink()
|
||||
name = src.stem
|
||||
parts = src.relative_to(lib_dir).with_suffix("").parts
|
||||
name = ".".join(parts)
|
||||
ident = "_".join(parts)
|
||||
comma = ", ".join(f"0x{b:02x}" for b in bytecode)
|
||||
arrays.append(f"static const unsigned char {name}[] = {{{comma}}};\n")
|
||||
rows.append(f' {{"{name}", {name}, sizeof({name})}},')
|
||||
arrays.append(f"static const unsigned char {ident}[] = {{{comma}}};\n")
|
||||
rows.append(f' {{"{name}", {ident}, sizeof({ident})}},')
|
||||
|
||||
footer = "const EmbeddedModule embedded_modules[] = {{\n{rows}\n}};\nconst size_t embedded_modules_count = {count};\n"
|
||||
generated = HEADER + "".join(arrays) + footer.format(rows="\n".join(rows), count=len(sources)) + FOOTER
|
||||
|
||||
Reference in New Issue
Block a user