-- Layout borrowed from CSS block flow: nesting plus box model, no cascade. -- Sizes are pixels (>= 1), a fraction of the parent content box (< 1), or "auto". 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 local function resolve(value, span, axis) if value == nil or value == "auto" then return nil 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"} local function inherit(child, parent) for _, key in ipairs(INHERITED) do if child[key] == nil then child[key] = parent[key] end 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, } local offset = 0 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 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 end child:place{x = x, y = y, w = cw, h = ch} end end function Component:draw() if self.dirty then if self.bg 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 for _, child in ipairs(self.children) do child.dirty = true 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 return self.on_press and self or nil end function Component:invalidate() self.dirty = true end local function component(spec) 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 function ui.text(label, spec) spec = spec or {} spec.label = label local node = component(spec) node.measure = function(self, available) self.mw = resolve(self.w, available.w, "width") or gui.textWidth(self.label) self.mh = resolve(self.h, available.h, "height") or gui.fontHeight() return self.mw, self.mh end node.paint = function(self) gui.drawText(self.label, self.rect.x, self.rect.y, self.color, self.bg) end return node end function ui.button(spec) spec.pad = spec.pad or 8 spec.align = spec.align or "center" if spec.label then spec.children = {ui.text(spec.label)} spec.label = nil end local node = component(spec) node.paint = function(self) local r = self.rect local radius = self.radius or 6 local fill = self.pressed and (self.press_bg or self.color) or (self.button_bg or self.bg) local gradient = self.pressed and self.press_gradient or (not self.pressed and self.gradient) if gradient then gui.fillRectGradient(r.x, r.y, r.w, r.h, radius, gradient[1], gradient[2]) fill = gradient[2] -- text blends against the bottom stop elseif fill then gui.fillRoundRect(r.x, r.y, r.w, r.h, radius, fill) end gui.drawRoundRect(r.x, r.y, r.w, r.h, radius, self.color) for _, child in ipairs(self.children) do child.bg = fill child.color = self.pressed and (self.press_color or self.bg) or self.color child.dirty = true end end return node end -- Screen ------------------------------------------------------------------- local Screen = {} Screen.__index = Screen function ui.screen(root, style) local screen = setmetatable({root = root, captured = nil, pressedAt = 0}, Screen) root.color = root.color or (style and style.color) or gui.color(0, 0, 0) root.bg = root.bg or (style and style.bg) or gui.color(255, 255, 255) screen:relayout() return screen end function Screen:relayout() local rect = {x = 0, y = 0, w = gui.width(), h = gui.height()} self.root:measure(rect) self.root:place(rect) gui.clear(self.root.bg) end function Screen:draw() self.root:draw() -- Hold the pressed look briefly so a fast tap is still perceptible. if self.released and sys.millis() - self.pressedAt >= PRESS_MS then self.released.pressed = false self.released:invalidate() self.released = nil end end function Screen:down(x, y) local target = self.root:hit(x, y) if not target then return end self.captured = target self.pressedAt = sys.millis() target.pressed = true target:invalidate() end function Screen:up(x, y) local target = self.captured self.captured = nil if not target then return end self.released = target local inside = contains(target.rect, x, y) if inside and target.on_press then target.on_press(target) end end return ui