652 lines
17 KiB
Lua
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,
|
|
} |