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:
+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
|
||||
|
||||
Reference in New Issue
Block a user