7c0928d84e
The firmware now fires on_touch_move with a 2px noise threshold; gesture intent is the toolkit's 8px slop, since a painting app's stroke starts at the first real pixel. Past the slop, Screen:move hands the drag to the nearest scroll ancestor and cancels the widget under the finger. ui.scroll leans on the app viewport for clipping, so it must reach both panel edges. Paint and Scroll demo both halves of the gesture split.
628 lines
22 KiB
Lua
628 lines
22 KiB
Lua
-- Layout borrowed from CSS block flow: nesting plus box model, no cascade.
|
|
-- Sizes are pixels (>= 1), a fraction of the parent content box (< 1), "fill" for all
|
|
-- of it, or "auto" to size to content.
|
|
|
|
local ui = {}
|
|
|
|
local PRESS_MS = 80 -- a fast tap must stay visible for at least this long
|
|
local SLOP = 4 -- resistive panels land a few pixels off
|
|
-- Intent, not noise: below this a moving finger is still a tap. Android uses 8dp and iOS
|
|
-- about 10pt before a scroller steals the touch, and this panel is near 1x either way.
|
|
-- The firmware's own 2px threshold is the noise floor and is a separate number.
|
|
local DRAG_SLOP = 8
|
|
|
|
-- Theme --------------------------------------------------------------------
|
|
-- Inheritance carries context (what surface am I on); the theme carries constants
|
|
-- (what is the accent). Roles are derived from three seeds so a new component costs
|
|
-- no theme keys, and so a theme cannot state its own contrast wrongly.
|
|
|
|
local BLACK, WHITE = {0, 0, 0}, {255, 255, 255}
|
|
local FALLBACK = {bg = WHITE, fg = BLACK, accent = {0, 120, 255}}
|
|
local PINNED = {muted = "color", accent_fg = "color", face = "pair", face_pressed = "pair", radius = "number"}
|
|
|
|
local function channel(value)
|
|
return math.max(0, math.min(math.floor(value + 0.5), 255))
|
|
end
|
|
|
|
local function mix(a, b, amount)
|
|
local blended = {}
|
|
for i = 1, 3 do blended[i] = channel(a[i] + (b[i] - a[i]) * amount) end
|
|
return blended
|
|
end
|
|
|
|
-- Perceived brightness, so text on the accent is chosen rather than configured.
|
|
local function luminance(c)
|
|
return (c[1] * 299 + c[2] * 587 + c[3] * 114) / 1000
|
|
end
|
|
|
|
local function rgb(c)
|
|
return gui.color(c[1], c[2], c[3])
|
|
end
|
|
|
|
local function palette(seed)
|
|
local bg, fg, accent = seed.bg, seed.fg, seed.accent
|
|
local theme = {
|
|
bg = rgb(bg),
|
|
fg = rgb(fg),
|
|
muted = rgb(mix(fg, bg, 0.45)),
|
|
accent = rgb(accent),
|
|
accent_fg = rgb(luminance(accent) > 140 and BLACK or WHITE),
|
|
face = {rgb(bg), rgb(mix(bg, fg, 0.08))},
|
|
face_pressed = {rgb(mix(accent, WHITE, 0.12)), rgb(mix(accent, BLACK, 0.12))},
|
|
radius = 6,
|
|
}
|
|
for role, kind in pairs(PINNED) do
|
|
local pin = seed[role]
|
|
if pin ~= nil then
|
|
if kind == "color" then theme[role] = rgb(pin)
|
|
elseif kind == "pair" then theme[role] = {rgb(pin[1]), rgb(pin[2])}
|
|
else theme[role] = pin end
|
|
end
|
|
end
|
|
return theme
|
|
end
|
|
|
|
-- Dimming is a black scrim at this strength. The panel has no alpha and there is no
|
|
-- framebuffer to blend against, so instead of compositing pixels the whole palette is
|
|
-- re-derived from darkened seeds: content behind a dialog is repainted in those colors,
|
|
-- which is what a scrim would have produced anyway.
|
|
local SCRIM = 0.45
|
|
|
|
local function dimSeed(seed)
|
|
local out = {}
|
|
for role, value in pairs(seed) do
|
|
if type(value) ~= "table" then out[role] = value
|
|
elseif type(value[1]) == "table" then out[role] = {mix(value[1], BLACK, SCRIM), mix(value[2], BLACK, SCRIM)}
|
|
else out[role] = mix(value, BLACK, SCRIM) end
|
|
end
|
|
return out
|
|
end
|
|
|
|
local function themes()
|
|
local ok, loaded = pcall(require, "theme")
|
|
return ok and type(loaded) == "table" and loaded or {}
|
|
end
|
|
|
|
function ui.themeNames()
|
|
local names = {}
|
|
for name in pairs(themes()) do names[#names + 1] = name end
|
|
table.sort(names)
|
|
return names
|
|
end
|
|
|
|
-- Saves the theme and reloads the palette. The firmware stores only the name and cannot
|
|
-- call back into Lua, so this is the seam that keeps the two halves from drifting.
|
|
function ui.setTheme(name)
|
|
local ok = settings.setTheme(name)
|
|
ui.reloadTheme()
|
|
return ok
|
|
end
|
|
|
|
-- Re-read the saved theme. Called at load, and by ui.setTheme(), so a changed theme takes
|
|
-- effect without relaunching.
|
|
function ui.reloadTheme()
|
|
ui.themeName = settings.getTheme and settings.getTheme() or "light"
|
|
local available = themes()
|
|
local seed = available[ui.themeName] or available.light or FALLBACK
|
|
ui.theme = palette(seed)
|
|
ui.theme.dim = palette(dimSeed(seed))
|
|
return ui.theme
|
|
end
|
|
|
|
ui.reloadTheme()
|
|
|
|
local function resolve(value, span, axis)
|
|
if value == nil or value == "auto" then return nil end
|
|
-- "fill" exists because a fraction cannot say 1.0: any number >= 1 is a pixel count,
|
|
-- so w = 1.0 asks for a single pixel. Covering a parent needs a word, not a number.
|
|
if value == "fill" then
|
|
if span == nil then
|
|
error("fill " .. axis .. " inside an auto-sized parent", 3)
|
|
end
|
|
return span
|
|
end
|
|
if value < 1 then
|
|
if span == nil then
|
|
error("fractional " .. axis .. " inside an auto-sized parent", 3)
|
|
end
|
|
return math.floor(value * span)
|
|
end
|
|
return math.floor(value)
|
|
end
|
|
|
|
local function padding(spec)
|
|
local p = spec.pad or 0
|
|
if type(p) == "number" then return {t = p, r = p, b = p, l = p} end
|
|
return {t = p.t or 0, r = p.r or 0, b = p.b or 0, l = p.l or 0}
|
|
end
|
|
|
|
-- The palette set on the root reaches every descendant, so styling is one place.
|
|
local INHERITED = {"color", "bg", "size", "button_bg", "press_bg", "press_color", "radius",
|
|
"gradient", "press_gradient", "dimmed"}
|
|
|
|
-- The root seeds the tree, so an app that names no colors is themed by inheritance.
|
|
local ROOT_STYLE = {
|
|
color = "fg", bg = "bg", press_bg = "accent", press_color = "accent_fg",
|
|
gradient = "face", press_gradient = "face_pressed", radius = "radius",
|
|
}
|
|
|
|
local function seedFrom(node, palette, style)
|
|
for key, role in pairs(ROOT_STYLE) do
|
|
if node[key] == nil then node[key] = (style and style[key]) or palette[role] end
|
|
end
|
|
end
|
|
|
|
local function inherit(child, parent)
|
|
-- Crossing into or out of a dimmed region restyles from the matching palette instead
|
|
-- of inheriting its neighbour's, which is how a dialog stays lit over dimmed content.
|
|
if child.dimmed ~= nil and child.dimmed ~= parent.dimmed then
|
|
seedFrom(child, child.dimmed and ui.theme.dim or ui.theme)
|
|
end
|
|
for _, key in ipairs(INHERITED) do
|
|
if child[key] == nil then child[key] = parent[key] end
|
|
end
|
|
-- What this node sits on, which is not the same as what it fills. Anti-aliased edges
|
|
-- blend into the surface, so a rounded card needs the color behind it and not its own.
|
|
if child.surface == nil then child.surface = parent.bg or parent.surface end
|
|
end
|
|
|
|
local function contains(rect, x, y)
|
|
return x >= rect.x - SLOP and x < rect.x + rect.w + SLOP
|
|
and y >= rect.y - SLOP and y < rect.y + rect.h + SLOP
|
|
end
|
|
|
|
-- Components ---------------------------------------------------------------
|
|
|
|
local Component = {}
|
|
Component.__index = Component
|
|
|
|
function Component:measure(available)
|
|
local pad = self.pad_
|
|
local w = resolve(self.w, available.w, "width")
|
|
local h = resolve(self.h, available.h, "height")
|
|
local inner = {
|
|
w = w and w - pad.l - pad.r or (available.w and available.w - pad.l - pad.r),
|
|
h = h and h - pad.t - pad.b or nil,
|
|
}
|
|
|
|
local main, cross = 0, 0
|
|
for index, child in ipairs(self.children) do
|
|
inherit(child, self)
|
|
local cw, ch = child:measure(inner)
|
|
if self.row then
|
|
main = main + cw + (index > 1 and self.gap or 0)
|
|
cross = math.max(cross, ch)
|
|
else
|
|
main = main + ch + (index > 1 and self.gap or 0)
|
|
cross = math.max(cross, cw)
|
|
end
|
|
end
|
|
|
|
if self.row then
|
|
self.mw = w or main + pad.l + pad.r
|
|
self.mh = h or cross + pad.t + pad.b
|
|
else
|
|
self.mw = w or cross + pad.l + pad.r
|
|
self.mh = h or main + pad.t + pad.b
|
|
end
|
|
return self.mw, self.mh
|
|
end
|
|
|
|
function Component:place(rect)
|
|
self.rect = rect
|
|
self.dirty = true
|
|
local pad = self.pad_
|
|
local content = {
|
|
x = rect.x + pad.l,
|
|
y = rect.y + pad.t,
|
|
w = rect.w - pad.l - pad.r,
|
|
h = rect.h - pad.t - pad.b,
|
|
}
|
|
|
|
-- Main-axis distribution, CSS justify-content minus the modes nothing here asks for.
|
|
-- Absolutely placed children take no part in the flow, so they are excluded.
|
|
local flowing, used = 0, 0
|
|
for _, child in ipairs(self.children) do
|
|
if not child.at then
|
|
flowing = flowing + 1
|
|
used = used + (self.row and child.mw or child.mh)
|
|
end
|
|
end
|
|
used = used + math.max(0, flowing - 1) * self.gap
|
|
local free = math.max(0, (self.row and content.w or content.h) - used)
|
|
|
|
local offset, spread = 0, 0
|
|
if self.justify == "end" then offset = free
|
|
elseif self.justify == "center" then offset = math.floor(free / 2)
|
|
elseif self.justify == "between" and flowing > 1 then spread = math.floor(free / (flowing - 1))
|
|
end
|
|
|
|
for _, child in ipairs(self.children) do
|
|
local cw, ch = child.mw, child.mh
|
|
-- Cross axis fills the parent unless the child asked for a size, like CSS blocks.
|
|
if self.row then
|
|
if child.h == nil or child.h == "auto" then ch = content.h end
|
|
else
|
|
if child.w == nil or child.w == "auto" then cw = content.w end
|
|
end
|
|
|
|
local x, y
|
|
if child.at then
|
|
x = content.x + resolve(child.at.x, content.w, "x")
|
|
y = content.y + resolve(child.at.y, content.h, "y")
|
|
elseif self.row then
|
|
x, y = content.x + offset, content.y
|
|
if self.align == "center" then y = y + math.floor((content.h - ch) / 2)
|
|
elseif self.align == "end" then y = y + content.h - ch end
|
|
offset = offset + cw + self.gap + spread
|
|
else
|
|
x, y = content.x, content.y + offset
|
|
if self.align == "center" then x = x + math.floor((content.w - cw) / 2)
|
|
elseif self.align == "end" then x = x + content.w - cw end
|
|
offset = offset + ch + self.gap + spread
|
|
end
|
|
child.parent = self -- a drag has to find the scroller above whatever it landed on
|
|
child:place{x = x, y = y, w = cw, h = ch}
|
|
end
|
|
end
|
|
|
|
function Component:draw()
|
|
if self.dirty then
|
|
if self.bg and not self.paintsBackground then
|
|
gui.fillRect(self.rect.x, self.rect.y, self.rect.w, self.rect.h, self.bg)
|
|
end
|
|
if self.paint then self:paint() end
|
|
self.dirty = false
|
|
self.dirtyParts = nil
|
|
for _, child in ipairs(self.children) do child.dirty = true end
|
|
elseif self.dirtyParts then
|
|
local parts = self.dirtyParts
|
|
self.dirtyParts = nil
|
|
for part in pairs(parts) do self:paint_part(part) end
|
|
end
|
|
for _, child in ipairs(self.children) do child:draw() end
|
|
end
|
|
|
|
-- Deepest interactive component wins, so a tappable child beats its tappable parent.
|
|
function Component:hit(x, y)
|
|
if not self.rect or not contains(self.rect, x, y) then return nil end
|
|
for index = #self.children, 1, -1 do
|
|
local found = self.children[index]:hit(x, y)
|
|
if found then return found end
|
|
end
|
|
-- A capturing component swallows the taps its children missed, so what it covers
|
|
-- cannot be tapped through. Without it the hit walk falls back to earlier siblings,
|
|
-- which is how a dialog would let you press the button underneath it.
|
|
if self.capture then return self end
|
|
return self.on_press and self or nil
|
|
end
|
|
|
|
function Component:invalidate()
|
|
self.dirty = true
|
|
end
|
|
|
|
function Component:invalidatePart(part)
|
|
if part == nil or not self.paint_part then return self:invalidate() end
|
|
if self.dirty then return end
|
|
self.dirtyParts = self.dirtyParts or {}
|
|
self.dirtyParts[part] = true
|
|
end
|
|
|
|
local function component(spec)
|
|
-- A bordered box is drawn as a rounded rect over its own background fill. The fill is
|
|
-- square and the border is not, but both are the same color as whatever sits behind a
|
|
-- box on this panel, so the corners have nothing to give away.
|
|
-- A bordered box paints its own background as a rounded rect, so the square fill in
|
|
-- Component:draw is suppressed. Filling first would leave square corners outside the
|
|
-- border, which is invisible against a matching surface and obvious against any other.
|
|
if spec.border then
|
|
spec.paintsBackground = true
|
|
spec.paint = function(self)
|
|
local r = self.rect
|
|
gui.roundRect(r.x, r.y, r.w, r.h, self.radius or ui.theme.radius, self.surface,
|
|
self.bg, self.bg, self.border)
|
|
end
|
|
end
|
|
spec.children = spec.children or {}
|
|
for index, child in ipairs(spec) do
|
|
spec.children[index] = child
|
|
spec[index] = nil
|
|
end
|
|
spec.gap = spec.gap or 0
|
|
spec.align = spec.align or "start"
|
|
spec.pad_ = padding(spec)
|
|
return setmetatable(spec, Component)
|
|
end
|
|
|
|
ui.box = component
|
|
|
|
function ui.spacer(spec)
|
|
return component{w = spec.w, h = spec.h}
|
|
end
|
|
|
|
-- Text size is panel state, not node state, so every measure and paint sets it: a
|
|
-- neighbour at another size would otherwise decide this node's metrics.
|
|
local function measureText(self, available)
|
|
gui.setTextSize(self.size or 1)
|
|
self.mw = resolve(self.w, available.w, "width") or gui.getTextWidth(self.label)
|
|
self.mh = resolve(self.h, available.h, "height") or gui.getFontHeight()
|
|
return self.mw, self.mh
|
|
end
|
|
|
|
local function paintText(self)
|
|
gui.setTextSize(self.size or 1)
|
|
local x = self.rect.x
|
|
if self.text_align == "center" then x = x + (self.rect.w - gui.getTextWidth(self.label)) // 2
|
|
elseif self.text_align == "end" then x = x + self.rect.w - gui.getTextWidth(self.label) end
|
|
gui.drawText(self.label, x, self.rect.y, self.color, self.bg)
|
|
end
|
|
|
|
local function setText(self, text)
|
|
if text == self.label then return end
|
|
self.label = text
|
|
self:invalidate()
|
|
end
|
|
|
|
function ui.text(label, spec)
|
|
spec = spec or {}
|
|
spec.label = label
|
|
local node = component(spec)
|
|
node.measure = measureText
|
|
node.paint = paintText
|
|
node.setText = setText
|
|
return node
|
|
end
|
|
|
|
-- Geometry for a grid of square cards: a wide frame takes another column, and the side
|
|
-- shrinks until every row fits, because there is no scrolling and a card below the fold
|
|
-- cannot be tapped. `reserve` is height the caller needs for anything under the grid.
|
|
-- ponytail: enough cards make them unusably small; that is the point to add scrolling.
|
|
function ui.cardSide(count, pad, gap, reserve)
|
|
local cols = gui.getWidth() >= gui.getHeight() and 4 or 3
|
|
local rows = math.ceil(count / cols)
|
|
local byWidth = (gui.getWidth() - 2 * pad - (cols - 1) * gap) // cols
|
|
local byHeight = (gui.getHeight() - 2 * pad - (reserve or 0) - (rows - 1) * gap) // rows
|
|
return math.min(byWidth, byHeight), cols
|
|
end
|
|
|
|
-- A text node sized to its own glyphs, so a centering parent has something to center: a
|
|
-- plain ui.text takes the parent's full width and paints from its left edge. `fit` clips
|
|
-- the label to a pixel budget, because the panel has one font and no wrapping.
|
|
function ui.label(text, spec)
|
|
spec = spec or {}
|
|
gui.setTextSize(spec.size or 1)
|
|
if spec.fit and gui.getTextWidth(text) > spec.fit then
|
|
while #text > 1 and gui.getTextWidth(text .. "~") > spec.fit do text = text:sub(1, -2) end
|
|
text = text .. "~"
|
|
end
|
|
spec.w, spec.fit = gui.getTextWidth(text), nil
|
|
return ui.text(text, spec)
|
|
end
|
|
|
|
local function paintButton(self)
|
|
local r = self.rect
|
|
local gradient = self.pressed and self.press_gradient or (not self.pressed and self.gradient)
|
|
local top, bottom
|
|
if gradient then
|
|
top, bottom = gradient[1], gradient[2]
|
|
else
|
|
top = self.pressed and (self.press_bg or self.color) or self.button_bg
|
|
bottom = top
|
|
end
|
|
gui.roundRect(r.x, r.y, r.w, r.h, self.radius or ui.theme.radius, self.bg, top, bottom, self.color)
|
|
for _, child in ipairs(self.children) do
|
|
-- No background for a gradient face: an opaque glyph fill is one flat colour, which
|
|
-- only matches the one row of the gradient it was taken from. The face is repainted
|
|
-- above on every change, so the children have nothing to erase.
|
|
-- `x and nil or y` can never be nil, so this stays an if.
|
|
if gradient then child.bg = nil else child.bg = bottom or self.bg end
|
|
child.color = self.pressed and (self.press_color or self.bg) or self.color
|
|
child.dirty = true
|
|
end
|
|
end
|
|
|
|
local function setButtonText(self, text)
|
|
local label = self.children[1]
|
|
if text == label.label then return end
|
|
label:setText(text)
|
|
self:invalidate()
|
|
end
|
|
|
|
function ui.button(spec)
|
|
spec.pad = spec.pad or 8
|
|
spec.align = spec.align or "center"
|
|
local hasLabel = spec.label ~= nil
|
|
if hasLabel then
|
|
spec.children = {ui.text(spec.label)}
|
|
spec.label = nil
|
|
end
|
|
local node = component(spec)
|
|
node.paint = paintButton
|
|
if hasLabel then node.setText = setButtonText end
|
|
return node
|
|
end
|
|
|
|
-- A dialog is not a layer the toolkit manages: it is a node an app includes when its
|
|
-- state calls for one, placed absolutely so it covers the flow instead of joining it.
|
|
-- Dismissing it means rebuilding without it, the same way every other state change works.
|
|
function ui.confirm(spec)
|
|
local card = {
|
|
w = spec.w or 0.85,
|
|
pad = 16,
|
|
gap = 12,
|
|
bg = ui.theme.bg, -- opaque: this is what hides the content behind
|
|
border = spec.border or ui.theme.muted,
|
|
ui.text(spec.title),
|
|
}
|
|
if spec.message then card[#card + 1] = ui.text(spec.message, {color = ui.theme.muted}) end
|
|
|
|
local buttons = {row = true, gap = 8, justify = "end"}
|
|
if spec.cancel ~= false then
|
|
buttons[#buttons + 1] = ui.button{label = spec.cancel or "cancel", on_press = spec.on_cancel}
|
|
end
|
|
buttons[#buttons + 1] = ui.button{label = spec.ok or "ok", on_press = spec.on_ok}
|
|
card[#card + 1] = ui.box(buttons)
|
|
|
|
-- bg = false leaves the app's own screen visible around the card; capture stops it
|
|
-- being tapped. on_press is deliberately absent, so a stray touch answers nothing.
|
|
return ui.box{
|
|
at = {x = 0, y = 0}, w = "fill", h = "fill",
|
|
bg = false, capture = true, dimmed = false, -- the card is lit, whatever is behind it
|
|
align = "center", justify = "center",
|
|
on_press = spec.on_outside,
|
|
ui.box(card),
|
|
}
|
|
end
|
|
|
|
-- Scrolling ----------------------------------------------------------------
|
|
-- Clipping is the app viewport the firmware already applies, so rows running past the
|
|
-- top or bottom edge are cut off by the panel. That holds only while the box reaches
|
|
-- both edges; a scroll box inset from them would paint over its neighbours.
|
|
-- ponytail: full repaint of the box per scroll step, roughly 10fps of drag on this SPI
|
|
-- bus. A gui.setClip() binding plus a blit of the unchanged band is the upgrade.
|
|
|
|
local function measureScroll(self, available)
|
|
-- The content measures unbounded on the main axis: what does not fit is what scrolls.
|
|
local h = self.h
|
|
self.h = nil
|
|
local _, content = Component.measure(self, {w = available.w, h = nil})
|
|
self.h = h
|
|
self.contentH = content
|
|
self.mw = resolve(self.w, available.w, "width") or available.w
|
|
self.mh = resolve(self.h, available.h, "height") or available.h
|
|
return self.mw, self.mh
|
|
end
|
|
|
|
local function placeScroll(self, rect)
|
|
self.max = math.max(0, self.contentH - rect.h)
|
|
self.offset = math.max(0, math.min(self.offset, self.max))
|
|
Component.place(self, {x = rect.x, y = rect.y - self.offset, w = rect.w, h = self.contentH})
|
|
self.rect = rect -- children flowed against the scrolled rect; hits use the real one
|
|
end
|
|
|
|
local function scrollBy(self, delta)
|
|
local offset = math.max(0, math.min(self.offset + delta, self.max))
|
|
if offset == self.offset then return false end
|
|
self.offset = offset
|
|
self:place(self.rect)
|
|
self:invalidate()
|
|
return true
|
|
end
|
|
|
|
function ui.scroll(spec)
|
|
spec.offset = spec.offset or 0
|
|
spec.scroll = true
|
|
-- Capturing makes a drag that starts on the background belong to the list; without a
|
|
-- press style, because the list itself is not a button.
|
|
spec.capture = true
|
|
spec.press_style = false
|
|
local node = component(spec)
|
|
node.measure = measureScroll
|
|
node.place = placeScroll
|
|
node.scrollBy = scrollBy
|
|
return node
|
|
end
|
|
|
|
local function scrollerFor(node)
|
|
while node do
|
|
if node.scroll then return node end
|
|
node = node.parent
|
|
end
|
|
end
|
|
|
|
-- Screen -------------------------------------------------------------------
|
|
|
|
local Screen = {}
|
|
Screen.__index = Screen
|
|
|
|
function ui.screen(root, style)
|
|
local screen = setmetatable({root = root, captured = nil, pressedAt = 0}, Screen)
|
|
-- The root is placed at the full panel rect, so it must measure that way too. Leaving
|
|
-- it auto made its height unknown to its own children, and a child asking for a
|
|
-- fraction of the screen failed inside the one component whose size is never in doubt.
|
|
if root.w == nil then root.w = "fill" end
|
|
if root.h == nil then root.h = "fill" end
|
|
seedFrom(root, root.dimmed and ui.theme.dim or ui.theme, style)
|
|
screen:relayout()
|
|
return screen
|
|
end
|
|
|
|
function Screen:relayout()
|
|
local rect = {x = 0, y = 0, w = gui.getWidth(), h = gui.getHeight()}
|
|
self.root:measure(rect)
|
|
self.root:place(rect)
|
|
gui.clear(self.root.bg)
|
|
end
|
|
|
|
local function invalidatePressed(target, part)
|
|
if target.invalidate_press then target:invalidate_press(part) else target:invalidate() end
|
|
end
|
|
|
|
local function finishRelease(screen)
|
|
local released = screen.released
|
|
if not released then return end
|
|
released.target.pressed = false
|
|
invalidatePressed(released.target, released.part)
|
|
screen.released = nil
|
|
end
|
|
|
|
function Screen:draw()
|
|
-- Same-Frame Release - Queue the normal state before drawing so it does not wait for another frame.
|
|
if self.released and sys.getMillis() - self.released.pressedAt >= PRESS_MS then
|
|
finishRelease(self)
|
|
end
|
|
self.root:draw()
|
|
end
|
|
|
|
function Screen:down(x, y)
|
|
-- Overlapping Presses - A second tap can begin during the first tap's 80 ms hold. Finish the old visual before changing a custom component's active sub-part.
|
|
finishRelease(self)
|
|
local target = self.root:hit(x, y)
|
|
if not target then return end
|
|
self.captured = target
|
|
self.pressedAt = sys.getMillis()
|
|
self.downX, self.downY = x, y
|
|
self.scrolling = nil
|
|
self.capturedPart = target.on_down and target.on_down(target, x, y) or nil
|
|
if target.press_style ~= false then
|
|
target.pressed = true
|
|
invalidatePressed(target, self.capturedPart)
|
|
end
|
|
end
|
|
|
|
-- Beyond the slop the gesture belongs to the enclosing scroller, if there is one. The
|
|
-- widget under the finger is cancelled rather than merely un-pressed: Android sends
|
|
-- ACTION_CANCEL here, and for the same reason its on_press must not fire on release.
|
|
function Screen:move(x, y)
|
|
if self.scrolling then
|
|
self.scrolling:scrollBy(self.dragY - y) -- content follows the finger
|
|
self.dragY = y
|
|
return
|
|
end
|
|
local target = self.captured
|
|
if not target then return end
|
|
if math.abs(x - self.downX) < DRAG_SLOP and math.abs(y - self.downY) < DRAG_SLOP then return end
|
|
local scroller = scrollerFor(target)
|
|
if not scroller then return end -- nothing to scroll, so the press survives the wobble
|
|
if target.press_style ~= false then
|
|
target.pressed = false
|
|
invalidatePressed(target, self.capturedPart)
|
|
end
|
|
self.captured, self.capturedPart = nil, nil
|
|
self.scrolling, self.dragY = scroller, y
|
|
end
|
|
|
|
function Screen:up(x, y)
|
|
local target, part = self.captured, self.capturedPart
|
|
self.captured, self.capturedPart = nil, nil
|
|
self.scrolling = nil
|
|
if not target then return end
|
|
if target.press_style ~= false then
|
|
self.released = {target = target, part = part, pressedAt = self.pressedAt}
|
|
end
|
|
local inside = contains(target.rect, x, y)
|
|
if inside and target.on_press then target.on_press(target, x, y) end
|
|
end
|
|
|
|
return ui
|