fpv/source/libraries/playout.lua
2024-06-01 16:52:11 +03:00

652 lines
17 KiB
Lua

local gfx <const> = playdate.graphics
local geo <const> = playdate.geometry
-- inherit property values from parent nodes
local function inheritProperty(name, node, fallback)
local cursor = node
local value
local style
repeat
style = cursor.properties.style or {}
value = style[name] or cursor.properties[name]
cursor = cursor.parent
until value or (not cursor)
return value or fallback
end
-- generate a list of offsets for drawing stroke outlines
local function strokeOffsets(radius)
local offsets = {}
for x = -radius, radius do
for y = -radius, radius do
if x * x + y * y <= radius * radius then
table.insert(offsets, { x, y })
end
end
end
return offsets
end
local function round(n)
return math.floor(n)
end
local floor = math.floor
local function tableFromDefaults(options, defaults)
options = options or {}
local t = table.shallowcopy(options)
for key, val in pairs(defaults) do
t[key] = options[key] or defaults[key]
end
return t
end
-- constants for box layout
local kDirectionVertical = 1
local kDirectionHorizontal = 2
local kAlignStart = 1
local kAlignCenter = 2
local kAlignEnd = 3
local kAlignStretch = 4
local kAnchorTopLeft = 1
local kAnchorTopCenter = 2
local kAnchorTopRight = 3
local kAnchorCenterLeft = 4
local kAnchorCenter = 5
local kAnchorCenterRight = 6
local kAnchorBottomLeft = 7
local kAnchorBottomCenter = 8
local kAnchorBottomRight = 9
-- generic box node
local box = {}
box.__index = box
local defaultBoxProperties = {
minWidth = 1,
minHeight = 1,
maxWidth = 400,
maxHeight = 240,
width = nil,
height = nil,
scroll = false,
direction = kDirectionVertical,
padding = 0,
paddingTop = nil,
paddingLeft = nil,
paddingRight = nil,
paddingBottom = nil,
backgroundColor = nil,
backgroundAlpha = nil,
nineSlice = nil,
hAlign = kAlignCenter,
vAlign = kAlignCenter,
selfAlign = nil,
border = 0,
borderColor = gfx.kColorBlack,
borderRadius = 0,
spacing = 0,
font = nil,
fontFamily = nil,
flex = nil,
shadow = nil,
shadowAlpha = 0
}
local defaultDrawContext = {
maxWidth = math.huge,
maxHeight = math.huge
}
function box.new(properties, children)
properties = properties or {}
local o = {
properties = tableFromDefaults(properties, defaultBoxProperties),
children = children or {},
childRects = nil,
parent = nil,
scrollPos = 0,
style = properties.style or nil
}
setmetatable(o, box)
return o
end
function box:appendChild(child)
table.insert(self.children, child)
child.parent = self
end
function box:insertChild(position, child)
table.insert(self.children, position, child)
child.parent = self
end
function box:layout(context)
context = tableFromDefaults(context, defaultDrawContext)
local props = self.properties
if self.style then props = tableFromDefaults(self.style, props) end
local constrainedWidth = math.min(context.maxWidth, (props.width or props.maxWidth))
local constrainedHeight = math.min(context.maxHeight, (props.height or props.maxHeight))
local isVertical = props.direction == kDirectionVertical
local children = self.children
if props.scroll then
if isVertical then
constrainedHeight = math.huge
else
constrainedWidth = math.huge
end
end
-- compute padding from shorthands
local paddingTop = props.paddingTop or props.padding
local paddingLeft = props.paddingLeft or props.padding
local paddingRight = props.paddingRight or props.paddingLeft or props.padding
local paddingBottom = props.paddingBottom or props.paddingTop or props.padding
local shadow = props.shadow or 0
local availableWidth = constrainedWidth - paddingLeft - paddingRight
local availableHeight = constrainedHeight - paddingTop - paddingBottom - shadow
local childRects = {}
self.childRects = childRects
local child
local childFlex
local remainingHeight = availableHeight
local remainingWidth = availableWidth
local intrinsicWidth = 0
local intrinsicHeight = 0
local totalFlex = 0
for i = 1, #children do
if i > 1 then
if isVertical then
remainingHeight = remainingHeight - props.spacing
intrinsicHeight = intrinsicHeight + props.spacing
else
remainingWidth = remainingWidth - props.spacing
intrinsicWidth = intrinsicWidth + props.spacing
end
end
childFlex = nil
child = children[i]
-- determine intrinsic size of child node
local childRect = child:layout({
maxWidth = remainingWidth,
maxHeight = remainingHeight,
path = context.path .. '.' .. (child.properties.id or i)
})
-- accumulate flex if specified
childFlex = child.properties.flex
childRects[i] = childRect
if childFlex then
totalFlex = totalFlex + child.properties.flex
end
if isVertical then
if not childFlex then
remainingHeight = remainingHeight - childRect.height
end
intrinsicHeight = intrinsicHeight + childRect.height
intrinsicWidth = math.max(intrinsicWidth, childRect.width)
else
if not childFlex then
remainingWidth = remainingWidth - childRect.width
end
intrinsicWidth = intrinsicWidth + childRect.width
intrinsicHeight = math.max(intrinsicHeight, childRect.height)
end
end
local actualWidth = constrainedWidth
local actualHeight = constrainedHeight
if not props.width then
if (not isVertical) and totalFlex > 0 then
actualWidth = constrainedWidth
else
actualWidth = math.max(props.minWidth, math.min(intrinsicWidth + paddingLeft + paddingRight, props.maxWidth))
remainingWidth = 0
end
end
if not props.height then
if isVertical and totalFlex > 0 then
actualHeight = constrainedHeight
else
actualHeight = math.max(props.minHeight, math.min(intrinsicHeight + paddingTop + paddingBottom + shadow, props.maxHeight))
remainingHeight = 0
end
end
local rect = geo.rect.new(0, 0, floor(actualWidth), floor(actualHeight))
local innerWidth = actualWidth - paddingLeft - paddingRight
local innerHeight = actualHeight - paddingTop - paddingBottom - shadow
local child, x, y, childProps, align, flexRatio
-- set initial layout position
if isVertical then
y = paddingTop
if totalFlex == 0 then
if props.vAlign == kAlignCenter then y = y + floor(remainingHeight / 2) end
if props.vAlign == kAlignEnd then y = y + remainingHeight end
end
else
x = paddingLeft
if totalFlex == 0 then
if props.hAlign == kAlignCenter then x = x + floor(remainingWidth / 2) end
if props.hAlign == kAlignEnd then x = x + remainingWidth end
end
end
for i = 1, #childRects do
child = childRects[i]
childProps = children[i].properties or {}
childFlex = childProps.flex
local align
if isVertical then
x = paddingLeft
align = childProps.selfAlign or props.hAlign
if align == kAlignCenter then x = x + round(innerWidth - child.width) / 2 end
if align == kAlignEnd then x = x + innerWidth - child.width end
else
y = paddingTop
align = childProps.selfAlign or props.vAlign
if align == kAlignCenter then y = y + round(innerHeight - child.height) / 2 end
if align == kAlignEnd then y = y + innerHeight - child.height end
end
-- generate final layout rect for child node
if childFlex then
flexRatio = childFlex / totalFlex
if isVertical then
if align == kAlignStretch then
childRects[i] = geo.rect.new(x, y, innerWidth, floor(flexRatio * remainingHeight))
else
childRects[i] = geo.rect.new(x, y, child.width, floor(flexRatio * remainingHeight))
end
else
if align == kAlignStretch then
childRects[i] = geo.rect.new(x, y, floor(flexRatio * remainingWidth), innerHeight)
else
childRects[i] = geo.rect.new(x, y, floor(flexRatio * remainingWidth), child.height)
end
end
child = childRects[i]
else
if align == kAlignStretch then
if isVertical then
child.width = innerWidth
else
child.height = innerHeight
end
end
child:offset(x, y)
end
-- move positioning cursor to next position
if isVertical then
y = y + child.height + props.spacing
else
x = x + child.width + props.spacing
end
end
return rect
end
function box:draw(rect)
local props = self.properties
if self.style then props = tableFromDefaults(self.style, props) end
local border = props.border
self.rect = rect
local r = rect
if props.backgroundColor then
if props.shadow then
gfx.setColor(gfx.kColorBlack)
gfx.setDitherPattern(props.shadowAlpha or 0)
gfx.fillRoundRect(r, props.borderRadius)
gfx.setColor(props.backgroundColor)
if props.backgroundAlpha then
gfx.setDitherPattern(props.backgroundAlpha)
end
gfx.fillRoundRect(r.x, r.y, r.width, r.height - props.shadow, props.borderRadius)
else
gfx.setColor(props.backgroundColor)
if props.backgroundAlpha then
gfx.setDitherPattern(props.backgroundAlpha)
end
gfx.fillRoundRect(r, props.borderRadius)
end
end
-- figure out why nineslice leaks memory all over the place
-- if props.nineSlice then
-- props.nineSlice:drawInRect(rect)
-- end
if border > 0 then
gfx.setColor(props.borderColor)
gfx.setLineWidth(border)
gfx.setStrokeLocation(gfx.kStrokeInside)
if props.shadow then
gfx.drawRoundRect(r.x, r.y, r.width, r.height - props.shadow, props.borderRadius)
else
gfx.drawRoundRect(r, props.borderRadius)
end
end
for i = 1, #self.children do
local child = self.children[i]
local drawRect = self.childRects[i]:offsetBy(r.x, r.y)
child:draw(drawRect)
end
end
-- text node
local text = {}
text.__index = text
local defaultTextProperties = {
font = nil,
fontFamily = nil,
alignment = kTextAlignment.left,
leading = nil,
flex = nil,
wrap = true,
stroke = 0,
color = nil
}
function text.new(textContent, properties)
properties = properties or {}
local o = {
text = textContent,
properties = tableFromDefaults(properties, defaultTextProperties),
parent = nil,
style = properties.style or nil
}
setmetatable(o, text)
return o
end
function text:layout(context)
context = tableFromDefaults(context, defaultDrawContext)
local props = self.properties
if self.style then props = tableFromDefaults(self.style, props) end
local maxWidth = context.maxWidth or math.huge
local maxHeight = context.maxHeight or math.huge
gfx.pushContext()
local font = inheritProperty("font", self, nil)
local fontFamily = inheritProperty("fontFamily", self, nil)
if font then
gfx.setFont(font)
end
if fontFamily then
gfx.setFontFamily(fontFamily)
end
if props.wrap then
local idealWidth, idealHeight = gfx.getTextSizeForMaxWidth(self.text, maxWidth, props.leading)
local width = idealWidth
local height = math.min(idealHeight, maxHeight)
gfx.popContext()
return geo.rect.new(0, 0, width, height)
end
local width, height = gfx.getTextSize(self.text)
gfx.popContext()
return geo.rect.new(0, 0, math.min(width, maxWidth), height)
end
function text:draw(rect)
local props = self.properties
local alignment = props.alignment;
self.rect = rect
gfx.pushContext()
local font = inheritProperty("font", self, nil)
local fontFamily = inheritProperty("fontFamily", self, nil)
if font then
gfx.setFont(font)
end
if fontFamily then
gfx.setFontFamily(fontFamily)
end
if props.stroke > 0 then
local offsets = strokeOffsets(props.stroke)
gfx.setImageDrawMode(gfx.kDrawModeFillWhite)
for _, o in pairs(offsets) do
gfx.drawTextInRect(self.text, rect:offsetBy(o[1], o[2]), props.leading, "...", alignment)
end
gfx.setImageDrawMode(gfx.kDrawModeCopy)
end
if props.color then
if props.color == gfx.kColorBlack then
gfx.setImageDrawMode(gfx.kDrawModeFillBlack)
end
if props.color == gfx.kColorWhite then
gfx.setImageDrawMode(gfx.kDrawModeFillWhite)
end
end
gfx.drawTextInRect(self.text, rect, props.leading, "...", alignment)
gfx.setImageDrawMode(gfx.kDrawModeCopy)
gfx.popContext()
end
-- image node
local image = {}
image.__index = image
local defaultImageProperties = {
}
function image.new(img, properties)
properties = properties or {}
local o = {
img = img,
properties = tableFromDefaults(properties, defaultImageProperties),
parent = nil,
width = img.width,
height = img.height
}
setmetatable(o, image)
return o
end
function image:layout(context)
return geo.rect.new(0, 0, self.width, self.height)
end
function image:draw(rect)
self.rect = rect
self.img:draw(rect.x, rect.y)
end
-- top-level tree
local tree = {}
tree.__index = tree
local treeBuilders = {
box = box.new,
image = image.new,
text = text.new
}
local defaultTreeOptions = {
useCache = true
}
function tree.new(root, options)
options = tableFromDefaults(options, defaultTreeOptions)
local o = {
root = root or box.new(),
rect = nil,
img = nil,
sprite = nil,
useCache = options.useCache,
tabIndex = nil
}
if o.useCache then
o.cache = {}
end
setmetatable(o, tree)
-- set parent-child relationships
function walk(node)
if node.children then
for i = 1, #node.children do
node.children[i].parent = node
walk(node.children[i])
end
end
end
walk(root)
return o
end
function tree:build(builder)
return tree.new(builder(treeBuilders))
end
function tree:layout()
local rect = self.root:layout({
maxWidth = 400,
maxHeight = 240,
tree = self,
path = 'root'
})
self.rect = rect
end
function tree:draw()
if not self.rect then
self:layout()
end
local rect = self.rect
-- avoid needless reallocations
if not self.img or self.img.width ~= rect.width or self.img.height ~= rect.height then
self.img = gfx.image.new(rect.width, rect.height)
else
self.img:clear(gfx.kColorClear)
end
gfx.pushContext(self.img)
self.root:draw(rect)
gfx.popContext()
if self.sprite then
self.sprite:setImage(self.img)
end
return self.img
end
function tree:asSprite()
self.sprite = gfx.sprite.new()
self:draw()
return self.sprite
end
function tree:get(id)
if self.useCache and self.cache[id] then
return self.cache[id]
end
function walk(node)
if node.properties and node.properties.id == id then
return node
end
if node.children then
for i = 1, #node.children do
local found = walk(node.children[i])
if found then
if self.useCache then
self.cache[id] = found
end
return found
end
end
end
end
return walk(self.root)
end
function tree:computeTabIndex(id)
local tabIndex = {}
function walk(node)
if node.properties and node.properties.tabIndex then
table.insert(tabIndex, node)
end
if node.children then
for i = 1, #node.children do
walk(node.children[i])
end
end
end
walk(self.root)
table.sort(tabIndex, function(a, b)
return a.properties.tabIndex < b.properties.tabIndex
end)
self.tabIndex = tabIndex
end
-- util
function getRectAnchor(r, anchor)
horizontal = horizontal or kAlignCenter
vertical = vertical or kAlignCenter
local p = geo.point.new
local cx = r.x + r.width / 2
local cy = r.y + r.height / 2
if anchor == kAnchorTopLeft then return p(r.left, r.top) end
if anchor == kAnchorTopCenter then return p(cx, r.top) end
if anchor == kAnchorTopRight then return p(r.right, r.top) end
if anchor == kAnchorCenterLeft then return p(r.left, cy) end
if anchor == kAnchorCenter then return p(cx, cy) end
if anchor == kAnchorCenterRight then return p(r.right, cy) end
if anchor == kAnchorBottomLeft then return p(r.left, r.bottom) end
if anchor == kAnchorBottomCenter then return p(cx, r.bottom) end
if anchor == kAnchorBottomRight then return p(r.right, r.bottom) end
return p(cx, cy)
end
-- interface
playout = {
box = box,
text = text,
tree = tree,
image = image,
kDirectionHorizontal = kDirectionHorizontal,
kDirectionVertical = kDirectionVertical,
kAlignStart = kAlignStart,
kAlignCenter = kAlignCenter,
kAlignEnd = kAlignEnd,
kAlignStretch = kAlignStretch,
getRectAnchor = getRectAnchor,
kAnchorTopLeft = kAnchorTopLeft,
kAnchorTopCenter = kAnchorTopCenter,
kAnchorTopRight = kAnchorTopRight,
kAnchorCenterLeft = kAnchorCenterLeft,
kAnchorCenter = kAnchorCenter,
kAnchorCenterRight = kAnchorCenterRight,
kAnchorBottomLeft = kAnchorBottomLeft,
kAnchorBottomCenter = kAnchorBottomCenter,
kAnchorBottomRight = kAnchorBottomRight,
}