Initial commit
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
-- gui_yaml.lua
|
||||
-- Parses a YAML-like table (pre-parsed by a YAML lib) into GUI elements.
|
||||
-- Usage: local yaml = require("tinyyaml") (or lyaml, etc.)
|
||||
-- local def = yaml.parse(yaml_string)
|
||||
-- local root = gui_yaml.build(gui, def)
|
||||
|
||||
local gui_yaml = {}
|
||||
|
||||
-- ─────────────────────────────────────────────
|
||||
-- Helpers
|
||||
-- ─────────────────────────────────────────────
|
||||
|
||||
local function parseColor(v)
|
||||
if type(v) == "string" then
|
||||
return require("gui.core.color").new(v)
|
||||
elseif type(v) == "table" then
|
||||
-- {r, g, b} or {r, g, b, a} — values 0-255 or 0-1
|
||||
local r, g, b, a = v[1], v[2], v[3], v[4] or 255
|
||||
-- normalise if in 0-255 range
|
||||
if r > 1 or g > 1 or b > 1 then
|
||||
r, g, b = r/255, g/255, b/255
|
||||
if a > 1 then a = a/255 end
|
||||
end
|
||||
return {r, g, b, a}
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function parseDualDim(def)
|
||||
--[[
|
||||
YAML formats accepted:
|
||||
pos: [x, y] offset
|
||||
size: [w, h] offset
|
||||
scale-pos: [sx, sy] scale (0-1)
|
||||
scale-size: [sw, sh] scale (0-1)
|
||||
|
||||
Or shorthand flat:
|
||||
x, y, w, h, sx, sy, sw, sh
|
||||
]]
|
||||
local px = def.x or (def.pos and def.pos[1]) or 0
|
||||
local py = def.y or (def.pos and def.pos[2]) or 0
|
||||
local pw = def.w or def.width or (def.size and def.size[1]) or 0
|
||||
local ph = def.h or def.height or (def.size and def.size[2]) or 0
|
||||
local sx = def.sx or (def["scale-pos"] and def["scale-pos"][1]) or 0
|
||||
local sy = def.sy or (def["scale-pos"] and def["scale-pos"][2]) or 0
|
||||
local sw = def.sw or (def["scale-size"] and def["scale-size"][1]) or 0
|
||||
local sh = def.sh or (def["scale-size"] and def["scale-size"][2]) or 0
|
||||
return px, py, pw, ph, sx, sy, sw, sh
|
||||
end
|
||||
|
||||
local function applyShared(parent, obj, def)
|
||||
-- Color / border
|
||||
if def.color then obj.color = parseColor(def.color) end
|
||||
if def["border-color"] then obj.borderColor = parseColor(def["border-color"]) end
|
||||
if def["draw-border"] ~= nil then obj.drawBorder = def["draw-border"] end
|
||||
|
||||
-- Visibility
|
||||
if def.visible ~= nil then obj.visible = def.visible end
|
||||
if def.active ~= nil then obj.active = def.active end
|
||||
if def.visibility ~= nil then obj.visibility = def.visibility end
|
||||
|
||||
-- Rotation
|
||||
if def.rotation then obj.rotation = def.rotation end
|
||||
|
||||
-- Tag
|
||||
if def.tag then obj:tag(def.tag) end
|
||||
|
||||
-- Tags (multi)
|
||||
if def.tags then
|
||||
for _, t in ipairs(def.tags) do obj:setTag(t) end
|
||||
end
|
||||
|
||||
-- Form factor
|
||||
if def.form then
|
||||
local f = def.form
|
||||
if f == "circle" then
|
||||
local x, y, w, h, sx, sy, sw = parseDualDim(def)
|
||||
local r = def.radius or (w / 2)
|
||||
obj:makeCircle(x, y, r, sx, sy, sw, def.segments)
|
||||
elseif f == "arc" then
|
||||
local x, y, w, h, sx, sy, sw = parseDualDim(def)
|
||||
local r = def.radius or (w / 2)
|
||||
obj:makeArc(
|
||||
def["arc-type"] or "open",
|
||||
x, y, r, sx, sy, sw,
|
||||
def["angle-start"] or 0,
|
||||
def["angle-end"] or math.pi * 2,
|
||||
def.segments
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
-- Roundness
|
||||
if def.roundness then
|
||||
local r = def.roundness
|
||||
if type(r) == "table" then
|
||||
obj:setRoundness(r[1], r[2], r[3], r.side)
|
||||
elseif type(r) == "string" then
|
||||
-- "top" | "bottom" shorthand
|
||||
obj:setRoundness(5, 5, 30, r)
|
||||
else
|
||||
obj:setRoundness(r, r, 30)
|
||||
end
|
||||
end
|
||||
|
||||
-- Centering
|
||||
if def["center-x"] then obj:centerX(def["center-x"]) end
|
||||
if def["center-y"] then obj:centerY(def["center-y"]) end
|
||||
|
||||
-- Full frame shorthand
|
||||
if def["full-frame"] then obj:fullFrame() end
|
||||
|
||||
-- Square lock
|
||||
if def.square then obj.square = def.square end
|
||||
|
||||
-- Dragging
|
||||
if def.draggable then
|
||||
obj:enableDragging(
|
||||
type(def.draggable) == "number" and def.draggable or 1
|
||||
)
|
||||
end
|
||||
|
||||
-- Hierarchy
|
||||
if def["respect-hierarchy"] ~= nil then
|
||||
obj:respectHierarchy(def["respect-hierarchy"])
|
||||
end
|
||||
|
||||
-- Clip descendants
|
||||
if def["clip-descendants"] ~= nil then
|
||||
obj.clipDescendants = def["clip-descendants"]
|
||||
end
|
||||
|
||||
-- Effects (function reference by name — looked up via _G or a registry)
|
||||
if def.effect then
|
||||
local fn = type(def.effect) == "function"
|
||||
and def.effect
|
||||
or _G[def.effect]
|
||||
if fn then obj.effect = fn end
|
||||
end
|
||||
|
||||
-- Shader (name → looked up in _G)
|
||||
if def.shader then
|
||||
obj.shader = type(def.shader) == "userdata"
|
||||
and def.shader
|
||||
or _G[def.shader]
|
||||
end
|
||||
|
||||
-- Position on stack
|
||||
if def.stack then
|
||||
if def.stack == "top" then obj:topStack() end
|
||||
if def.stack == "bottom" then obj:bottomStack() end
|
||||
end
|
||||
end
|
||||
|
||||
local function applyEvents(obj, def, env)
|
||||
--[[
|
||||
Events in YAML can be:
|
||||
on-pressed: "myFunction" -- looks up _G or env
|
||||
on-pressed: |
|
||||
print("hello") -- raw Lua string, loaded as chunk
|
||||
]]
|
||||
local function resolve(v)
|
||||
if type(v) == "function" then return v end
|
||||
if type(v) == "string" then
|
||||
-- Try global lookup first
|
||||
if _G[v] and type(_G[v]) == "function" then return _G[v] end
|
||||
-- Otherwise treat as Lua source
|
||||
if env then
|
||||
return env[v]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local map = {
|
||||
["on-pressed"] = "OnPressed",
|
||||
["on-released"] = "OnReleased",
|
||||
["on-released-outer"] = "OnReleasedOuter",
|
||||
["on-pressed-outer"] = "OnPressedOuter",
|
||||
["on-enter"] = "OnEnter",
|
||||
["on-exit"] = "OnExit",
|
||||
["on-moved"] = "OnMoved",
|
||||
["on-drag-start"] = "OnDragStart",
|
||||
["on-dragging"] = "OnDragging",
|
||||
["on-drag-end"] = "OnDragEnd",
|
||||
["on-wheel"] = "OnWheelMoved",
|
||||
["on-size-changed"] = "OnSizeChanged",
|
||||
["on-position-changed"] = "OnPositionChanged",
|
||||
["on-destroy"] = "OnDestroy",
|
||||
["on-load"] = "OnLoad",
|
||||
["on-return"] = "OnReturn", -- textbox only
|
||||
}
|
||||
|
||||
for yaml_key, conn_key in pairs(map) do
|
||||
if def[yaml_key] and obj[conn_key] then
|
||||
local fn = resolve(def[yaml_key])
|
||||
if fn then obj[conn_key](fn) end
|
||||
end
|
||||
end
|
||||
|
||||
-- on-update is special (not a connection)
|
||||
if def["on-update"] then
|
||||
local fn = resolve(def["on-update"])
|
||||
if fn then obj:OnUpdate(fn) end
|
||||
end
|
||||
|
||||
-- Hotkeys
|
||||
if def.hotkeys then
|
||||
for _, hk in ipairs(def.hotkeys) do
|
||||
-- {keys: [lctrl, s], action: "mySaveFunction"}
|
||||
local fn = resolve(hk.action)
|
||||
if fn then obj:setHotKey(hk.keys)(fn) end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function applyTextProps(obj, def)
|
||||
if def.text then obj.text = tostring(def.text) end
|
||||
if def["text-color"] then obj.textColor = parseColor(def["text-color"]) end
|
||||
if def["text-visibility"] then obj.textVisibility = def["text-visibility"] end
|
||||
if def["text-scale"] then
|
||||
obj.textScaleX = def["text-scale"][1] or 1
|
||||
obj.textScaleY = def["text-scale"][2] or 1
|
||||
end
|
||||
if def["text-offset"] then
|
||||
obj.textOffsetX = def["text-offset"][1] or 0
|
||||
obj.textOffsetY = def["text-offset"][2] or 0
|
||||
end
|
||||
if def["text-shear"] then
|
||||
obj.textShearingFactorX = def["text-shear"][1] or 0
|
||||
obj.textShearingFactorY = def["text-shear"][2] or 0
|
||||
end
|
||||
|
||||
-- Alignment
|
||||
local alignMap = {left = 1, center = 0, right = 2}
|
||||
if def.align then
|
||||
obj.align = alignMap[def.align] or 1
|
||||
end
|
||||
|
||||
-- Font
|
||||
if def.font then
|
||||
local f = def.font
|
||||
if type(f) == "number" then
|
||||
obj:setFont(f)
|
||||
elseif type(f) == "string" then
|
||||
obj:setFont(f, def["font-size"])
|
||||
elseif type(f) == "table" then
|
||||
-- {file: "fonts/roboto.ttf", size: 18}
|
||||
obj:setFont(f.file or f[1], f.size or f[2])
|
||||
end
|
||||
end
|
||||
|
||||
-- fit-font: true | {min: 8, max: 200, scale: 1}
|
||||
if def["fit-font"] then
|
||||
local ff = def["fit-font"]
|
||||
if ff == true then
|
||||
obj:fitFont()
|
||||
elseif type(ff) == "table" then
|
||||
obj:fitFont(ff.min, ff.max, ff.scale and {scale=ff.scale} or nil)
|
||||
end
|
||||
end
|
||||
|
||||
-- center-font: true | offset
|
||||
if def["center-font"] then
|
||||
local cf = def["center-font"]
|
||||
obj:centerFont(type(cf) == "number" and cf or nil)
|
||||
end
|
||||
end
|
||||
|
||||
local function applyImageProps(obj, def)
|
||||
-- source: "path/to/image.png"
|
||||
-- tile: [x, y, w, h] (optional sub-quad)
|
||||
if def.source then
|
||||
if def.tile then
|
||||
local t = def.tile
|
||||
obj:setImage(def.source, t[1], t[2], t[3], t[4])
|
||||
else
|
||||
obj:setImage(def.source)
|
||||
end
|
||||
end
|
||||
|
||||
if def["scale-x"] then obj.scaleX = def["scale-x"] end
|
||||
if def["scale-y"] then obj.scaleY = def["scale-y"] end
|
||||
if def["image-color"] then obj.imageColor = parseColor(def["image-color"]) end
|
||||
if def["image-visibility"] then obj.imageVisibility = def["image-visibility"] end
|
||||
|
||||
-- flip: "horizontal" | "vertical" | "both"
|
||||
if def.flip then
|
||||
local fl = def.flip
|
||||
if fl == "horizontal" or fl == "both" then obj:flip(false) end
|
||||
if fl == "vertical" or fl == "both" then obj:flip(true) end
|
||||
end
|
||||
|
||||
-- gradient shorthand
|
||||
if def.gradient then
|
||||
local g = def.gradient
|
||||
-- {direction: "vertical", colors: [[r,g,b,a], ...]}
|
||||
local colors = {}
|
||||
for _, c in ipairs(g.colors) do
|
||||
colors[#colors+1] = parseColor(c)
|
||||
end
|
||||
obj:applyGradient(g.direction or "vertical", table.unpack(colors))
|
||||
end
|
||||
end
|
||||
|
||||
local function applyVideoProps(obj, def)
|
||||
if def.source then obj:setVideo(def.source) end
|
||||
if def.volume then obj:setVolume(def.volume) end
|
||||
if def.autoplay and def.autoplay then obj:play() end
|
||||
if def["video-color"] then obj.videoColor = parseColor(def["video-color"]) end
|
||||
if def["video-visibility"] then obj.videoVisibility = def["video-visibility"] end
|
||||
end
|
||||
|
||||
-- ─────────────────────────────────────────────
|
||||
-- Core builder
|
||||
-- ─────────────────────────────────────────────
|
||||
|
||||
local builders -- forward ref for recursion
|
||||
|
||||
builders = {
|
||||
["frame"] = function(parent, def)
|
||||
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
|
||||
return parent:newFrame(x,y,w,h,sx,sy,sw,sh)
|
||||
end,
|
||||
["virtual-frame"] = function(parent, def)
|
||||
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
|
||||
return parent:newVirtualFrame(x,y,w,h,sx,sy,sw,sh)
|
||||
end,
|
||||
["visual-frame"] = function(parent, def)
|
||||
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
|
||||
return parent:newVisualFrame(x,y,w,h,sx,sy,sw,sh)
|
||||
end,
|
||||
["label"] = function(parent, def)
|
||||
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
|
||||
return parent:newTextLabel(def.text or "", x,y,w,h,sx,sy,sw,sh)
|
||||
end,
|
||||
["button"] = function(parent, def)
|
||||
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
|
||||
return parent:newTextButton(def.text or "", x,y,w,h,sx,sy,sw,sh)
|
||||
end,
|
||||
["textbox"] = function(parent, def)
|
||||
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
|
||||
return parent:newTextBox(def.text or "", x,y,w,h,sx,sy,sw,sh)
|
||||
end,
|
||||
["image"] = function(parent, def)
|
||||
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
|
||||
return parent:newImageLabel(def.source, x,y,w,h,sx,sy,sw,sh)
|
||||
end,
|
||||
["image-button"] = function(parent, def)
|
||||
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
|
||||
return parent:newImageButton(def.source, x,y,w,h,sx,sy,sw,sh)
|
||||
end,
|
||||
["video"] = function(parent, def)
|
||||
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
|
||||
return parent:newVideo(def.source, x,y,w,h,sx,sy,sw,sh)
|
||||
end,
|
||||
}
|
||||
|
||||
local TEXT_TYPES = {label=true, button=true, textbox=true}
|
||||
local IMAGE_TYPES = {image=true, ["image-button"]=true}
|
||||
local VIDEO_TYPES = {video=true}
|
||||
|
||||
function gui_yaml.build(parent, def, env)
|
||||
--[[
|
||||
parent : gui element or gui root
|
||||
def : parsed YAML table (one element)
|
||||
env : optional Lua env table for event string resolution
|
||||
|
||||
Returns the created object (or nil on unknown type).
|
||||
]]
|
||||
local typ = def.type
|
||||
if not typ then
|
||||
error("gui_yaml: element missing 'type' field")
|
||||
end
|
||||
|
||||
local builder = builders[typ]
|
||||
if not builder then
|
||||
error("gui_yaml: unknown element type '" .. tostring(typ) .. "'")
|
||||
end
|
||||
|
||||
local obj = builder(parent, def)
|
||||
|
||||
-- Apply shared properties
|
||||
applyShared(parent, obj, def)
|
||||
|
||||
-- Apply type-specific properties
|
||||
if TEXT_TYPES[typ] then applyTextProps(obj, def) end
|
||||
if IMAGE_TYPES[typ] then applyImageProps(obj, def) end
|
||||
if VIDEO_TYPES[typ] then applyVideoProps(obj, def) end
|
||||
|
||||
-- Events
|
||||
applyEvents(obj, def, env)
|
||||
|
||||
-- Recurse into children
|
||||
if def.children then
|
||||
for _, child_def in ipairs(def.children) do
|
||||
gui_yaml.build(obj, child_def, env)
|
||||
end
|
||||
end
|
||||
|
||||
return obj
|
||||
end
|
||||
|
||||
function gui_yaml.buildMany(parent, defs, env)
|
||||
local results = {}
|
||||
for _, def in ipairs(defs) do
|
||||
results[#results+1] = gui_yaml.build(parent, def, env)
|
||||
end
|
||||
return results
|
||||
end
|
||||
|
||||
-- Convenience: parse a YAML string and build in one call.
|
||||
-- Requires a YAML library. Tries tinyyaml, then lyaml.
|
||||
function gui_yaml.fromString(parent, yaml_str, env)
|
||||
local ok, yaml = pcall(require, "gui.yaml.tinyyaml")
|
||||
if not ok then
|
||||
ok, yaml = pcall(require, "lyaml")
|
||||
if not ok then
|
||||
error("gui_yaml.fromString: no YAML library found (tried tinyyaml, lyaml)")
|
||||
end
|
||||
end
|
||||
local def = yaml.parse(yaml_str)
|
||||
-- Support both single-element and list-of-elements at root
|
||||
if def.type then
|
||||
return gui_yaml.build(parent, def, env)
|
||||
else
|
||||
return gui_yaml.buildMany(parent, def, env)
|
||||
end
|
||||
end
|
||||
|
||||
return gui_yaml
|
||||
@@ -0,0 +1,776 @@
|
||||
-------------------------------------------------------------------------------
|
||||
-- tinyyaml - YAML subset parser
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
local table = table
|
||||
local string = string
|
||||
local schar = string.char
|
||||
local ssub, gsub = string.sub, string.gsub
|
||||
local sfind, smatch = string.find, string.match
|
||||
local tinsert, tremove = table.insert, table.remove
|
||||
local setmetatable = setmetatable
|
||||
local pairs = pairs
|
||||
local type = type
|
||||
local tonumber = tonumber
|
||||
local math = math
|
||||
local getmetatable = getmetatable
|
||||
local error = error
|
||||
|
||||
local UNESCAPES = {
|
||||
['0'] = "\x00", z = "\x00", N = "\x85",
|
||||
a = "\x07", b = "\x08", t = "\x09",
|
||||
n = "\x0a", v = "\x0b", f = "\x0c",
|
||||
r = "\x0d", e = "\x1b", ['\\'] = '\\',
|
||||
};
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
-- utils
|
||||
local function select(list, pred)
|
||||
local selected = {}
|
||||
for i = 0, #list do
|
||||
local v = list[i]
|
||||
if v and pred(v, i) then
|
||||
tinsert(selected, v)
|
||||
end
|
||||
end
|
||||
return selected
|
||||
end
|
||||
|
||||
local function startswith(haystack, needle)
|
||||
return ssub(haystack, 1, #needle) == needle
|
||||
end
|
||||
|
||||
local function ltrim(str)
|
||||
return smatch(str, "^%s*(.-)$")
|
||||
end
|
||||
|
||||
local function rtrim(str)
|
||||
return smatch(str, "^(.-)%s*$")
|
||||
end
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
-- Implementation.
|
||||
--
|
||||
local class = {__meta={}}
|
||||
function class.__meta.__call(cls, ...)
|
||||
local self = setmetatable({}, cls)
|
||||
if cls.__init then
|
||||
cls.__init(self, ...)
|
||||
end
|
||||
return self
|
||||
end
|
||||
|
||||
function class.def(base, typ, cls)
|
||||
base = base or class
|
||||
local mt = {__metatable=base, __index=base}
|
||||
for k, v in pairs(base.__meta) do mt[k] = v end
|
||||
cls = setmetatable(cls or {}, mt)
|
||||
cls.__index = cls
|
||||
cls.__metatable = cls
|
||||
cls.__type = typ
|
||||
cls.__meta = mt
|
||||
return cls
|
||||
end
|
||||
|
||||
|
||||
local types = {
|
||||
null = class:def('null'),
|
||||
map = class:def('map'),
|
||||
omap = class:def('omap'),
|
||||
pairs = class:def('pairs'),
|
||||
set = class:def('set'),
|
||||
seq = class:def('seq'),
|
||||
timestamp = class:def('timestamp'),
|
||||
}
|
||||
|
||||
local Null = types.null
|
||||
function Null.__tostring() return 'yaml.null' end
|
||||
function Null.isnull(v)
|
||||
if v == nil then return true end
|
||||
if type(v) == 'table' and getmetatable(v) == Null then return true end
|
||||
return false
|
||||
end
|
||||
local null = Null()
|
||||
|
||||
function types.timestamp:__init(y, m, d, h, i, s, f, z)
|
||||
self.year = tonumber(y)
|
||||
self.month = tonumber(m)
|
||||
self.day = tonumber(d)
|
||||
self.hour = tonumber(h or 0)
|
||||
self.minute = tonumber(i or 0)
|
||||
self.second = tonumber(s or 0)
|
||||
if type(f) == 'string' and sfind(f, '^%d+$') then
|
||||
self.fraction = tonumber(f) * math.pow(10, 3 - #f)
|
||||
elseif f then
|
||||
self.fraction = f
|
||||
else
|
||||
self.fraction = 0
|
||||
end
|
||||
self.timezone = z
|
||||
end
|
||||
|
||||
function types.timestamp:__tostring()
|
||||
return string.format(
|
||||
'%04d-%02d-%02dT%02d:%02d:%02d.%03d%s',
|
||||
self.year, self.month, self.day,
|
||||
self.hour, self.minute, self.second, self.fraction,
|
||||
self:gettz())
|
||||
end
|
||||
|
||||
function types.timestamp:gettz()
|
||||
if not self.timezone then
|
||||
return ''
|
||||
end
|
||||
if self.timezone == 0 then
|
||||
return 'Z'
|
||||
end
|
||||
local sign = self.timezone > 0
|
||||
local z = sign and self.timezone or -self.timezone
|
||||
local zh = math.floor(z)
|
||||
local zi = (z - zh) * 60
|
||||
return string.format(
|
||||
'%s%02d:%02d', sign and '+' or '-', zh, zi)
|
||||
end
|
||||
|
||||
|
||||
local function countindent(line)
|
||||
local _, j = sfind(line, '^%s+')
|
||||
if not j then
|
||||
return 0, line
|
||||
end
|
||||
return j, ssub(line, j+1)
|
||||
end
|
||||
|
||||
local function parsestring(line, stopper)
|
||||
stopper = stopper or ''
|
||||
local q = ssub(line, 1, 1)
|
||||
if q == ' ' or q == '\t' then
|
||||
return parsestring(ssub(line, 2))
|
||||
end
|
||||
if q == "'" then
|
||||
local i = sfind(line, "'", 2, true)
|
||||
if not i then
|
||||
return nil, line
|
||||
end
|
||||
return ssub(line, 2, i-1), ssub(line, i+1)
|
||||
end
|
||||
if q == '"' then
|
||||
local i, buf = 2, ''
|
||||
while i < #line do
|
||||
local c = ssub(line, i, i)
|
||||
if c == '\\' then
|
||||
local n = ssub(line, i+1, i+1)
|
||||
if UNESCAPES[n] ~= nil then
|
||||
buf = buf..UNESCAPES[n]
|
||||
elseif n == 'x' then
|
||||
local h = ssub(i+2,i+3)
|
||||
if sfind(h, '^[0-9a-fA-F]$') then
|
||||
buf = buf..schar(tonumber(h, 16))
|
||||
i = i + 2
|
||||
else
|
||||
buf = buf..'x'
|
||||
end
|
||||
else
|
||||
buf = buf..n
|
||||
end
|
||||
i = i + 1
|
||||
elseif c == q then
|
||||
break
|
||||
else
|
||||
buf = buf..c
|
||||
end
|
||||
i = i + 1
|
||||
end
|
||||
return buf, ssub(line, i+1)
|
||||
end
|
||||
if q == '{' or q == '[' then -- flow style
|
||||
return nil, line
|
||||
end
|
||||
if q == '|' or q == '>' then -- block
|
||||
return nil, line
|
||||
end
|
||||
if q == '-' or q == ':' then
|
||||
if ssub(line, 2, 2) == ' ' or #line == 1 then
|
||||
return nil, line
|
||||
end
|
||||
end
|
||||
local buf = ''
|
||||
while #line > 0 do
|
||||
local c = ssub(line, 1, 1)
|
||||
if sfind(stopper, c, 1, true) then
|
||||
break
|
||||
elseif c == ':' and (ssub(line, 2, 2) == ' ' or #line == 1) then
|
||||
break
|
||||
elseif c == '#' and (ssub(buf, #buf, #buf) == ' ') then
|
||||
break
|
||||
else
|
||||
buf = buf..c
|
||||
end
|
||||
line = ssub(line, 2)
|
||||
end
|
||||
return rtrim(buf), line
|
||||
end
|
||||
|
||||
local function isemptyline(line)
|
||||
return line == '' or sfind(line, '^%s*$') or sfind(line, '^%s*#')
|
||||
end
|
||||
|
||||
local function equalsline(line, needle)
|
||||
return startswith(line, needle) and isemptyline(ssub(line, #needle+1))
|
||||
end
|
||||
|
||||
local function checkdupekey(map, key)
|
||||
if map[key] ~= nil then
|
||||
-- print("found a duplicate key '"..key.."' in line: "..line)
|
||||
local suffix = 1
|
||||
while map[key..'_'..suffix] do
|
||||
suffix = suffix + 1
|
||||
end
|
||||
key = key ..'_'..suffix
|
||||
end
|
||||
return key
|
||||
end
|
||||
|
||||
local function parseflowstyle(line, lines)
|
||||
local stack = {}
|
||||
while true do
|
||||
if #line == 0 then
|
||||
if #lines == 0 then
|
||||
break
|
||||
else
|
||||
line = tremove(lines, 1)
|
||||
end
|
||||
end
|
||||
local c = ssub(line, 1, 1)
|
||||
if c == '#' then
|
||||
line = ''
|
||||
elseif c == ' ' or c == '\t' or c == '\r' or c == '\n' then
|
||||
line = ssub(line, 2)
|
||||
elseif c == '{' or c == '[' then
|
||||
tinsert(stack, {v={},t=c})
|
||||
line = ssub(line, 2)
|
||||
elseif c == ':' then
|
||||
local s = tremove(stack)
|
||||
tinsert(stack, {v=s.v, t=':'})
|
||||
line = ssub(line, 2)
|
||||
elseif c == ',' then
|
||||
local value = tremove(stack)
|
||||
if value.t == ':' or value.t == '{' or value.t == '[' then error() end
|
||||
if stack[#stack].t == ':' then
|
||||
-- map
|
||||
local key = tremove(stack)
|
||||
key.v = checkdupekey(stack[#stack].v, key.v)
|
||||
stack[#stack].v[key.v] = value.v
|
||||
elseif stack[#stack].t == '{' then
|
||||
-- set
|
||||
stack[#stack].v[value.v] = true
|
||||
elseif stack[#stack].t == '[' then
|
||||
-- seq
|
||||
tinsert(stack[#stack].v, value.v)
|
||||
end
|
||||
line = ssub(line, 2)
|
||||
elseif c == '}' then
|
||||
if stack[#stack].t == '{' then
|
||||
if #stack == 1 then break end
|
||||
stack[#stack].t = '}'
|
||||
line = ssub(line, 2)
|
||||
else
|
||||
line = ','..line
|
||||
end
|
||||
elseif c == ']' then
|
||||
if stack[#stack].t == '[' then
|
||||
if #stack == 1 then break end
|
||||
stack[#stack].t = ']'
|
||||
line = ssub(line, 2)
|
||||
else
|
||||
line = ','..line
|
||||
end
|
||||
else
|
||||
local s, rest = parsestring(line, ',{}[]')
|
||||
if not s then
|
||||
error('invalid flowstyle line: '..line)
|
||||
end
|
||||
tinsert(stack, {v=s, t='s'})
|
||||
line = rest
|
||||
end
|
||||
end
|
||||
return stack[1].v, line
|
||||
end
|
||||
|
||||
local function parseblockstylestring(line, lines, indent)
|
||||
if #lines == 0 then
|
||||
error("failed to find multi-line scalar content")
|
||||
end
|
||||
local s = {}
|
||||
local firstindent = -1
|
||||
local endline = -1
|
||||
for i = 1, #lines do
|
||||
local ln = lines[i]
|
||||
local idt = countindent(ln)
|
||||
if idt <= indent then
|
||||
break
|
||||
end
|
||||
if ln == '' then
|
||||
tinsert(s, '')
|
||||
else
|
||||
if firstindent == -1 then
|
||||
firstindent = idt
|
||||
elseif idt < firstindent then
|
||||
break
|
||||
end
|
||||
tinsert(s, ssub(ln, firstindent + 1))
|
||||
end
|
||||
endline = i
|
||||
end
|
||||
|
||||
local striptrailing = true
|
||||
local sep = '\n'
|
||||
local newlineatend = true
|
||||
if line == '|' then
|
||||
striptrailing = true
|
||||
sep = '\n'
|
||||
newlineatend = true
|
||||
elseif line == '|+' then
|
||||
striptrailing = false
|
||||
sep = '\n'
|
||||
newlineatend = true
|
||||
elseif line == '|-' then
|
||||
striptrailing = true
|
||||
sep = '\n'
|
||||
newlineatend = false
|
||||
elseif line == '>' then
|
||||
striptrailing = true
|
||||
sep = ' '
|
||||
newlineatend = true
|
||||
elseif line == '>+' then
|
||||
striptrailing = false
|
||||
sep = ' '
|
||||
newlineatend = true
|
||||
elseif line == '>-' then
|
||||
striptrailing = true
|
||||
sep = ' '
|
||||
newlineatend = false
|
||||
else
|
||||
error('invalid blockstyle string:'..line)
|
||||
end
|
||||
local eonl = 0
|
||||
for i = #s, 1, -1 do
|
||||
if s[i] == '' then
|
||||
tremove(s, i)
|
||||
eonl = eonl + 1
|
||||
end
|
||||
end
|
||||
if striptrailing then
|
||||
eonl = 0
|
||||
end
|
||||
if newlineatend then
|
||||
eonl = eonl + 1
|
||||
end
|
||||
for i = endline, 1, -1 do
|
||||
tremove(lines, i)
|
||||
end
|
||||
return table.concat(s, sep)..string.rep('\n', eonl)
|
||||
end
|
||||
|
||||
local function parsetimestamp(line)
|
||||
local _, p1, y, m, d = sfind(line, '^(%d%d%d%d)%-(%d%d)%-(%d%d)')
|
||||
if not p1 then
|
||||
return nil, line
|
||||
end
|
||||
if p1 == #line then
|
||||
return types.timestamp(y, m, d), ''
|
||||
end
|
||||
local _, p2, h, i, s = sfind(line, '^[Tt ](%d+):(%d+):(%d+)', p1+1)
|
||||
if not p2 then
|
||||
return types.timestamp(y, m, d), ssub(line, p1+1)
|
||||
end
|
||||
if p2 == #line then
|
||||
return types.timestamp(y, m, d, h, i, s), ''
|
||||
end
|
||||
local _, p3, f = sfind(line, '^%.(%d+)', p2+1)
|
||||
if not p3 then
|
||||
p3 = p2
|
||||
f = 0
|
||||
end
|
||||
local zc = ssub(line, p3+1, p3+1)
|
||||
local _, p4, zs, z = sfind(line, '^ ?([%+%-])(%d+)', p3+1)
|
||||
if p4 then
|
||||
z = tonumber(z)
|
||||
local _, p5, zi = sfind(line, '^:(%d+)', p4+1)
|
||||
if p5 then
|
||||
z = z + tonumber(zi) / 60
|
||||
end
|
||||
z = zs == '-' and -tonumber(z) or tonumber(z)
|
||||
elseif zc == 'Z' then
|
||||
p4 = p3 + 1
|
||||
z = 0
|
||||
else
|
||||
p4 = p3
|
||||
z = false
|
||||
end
|
||||
return types.timestamp(y, m, d, h, i, s, f, z), ssub(line, p4+1)
|
||||
end
|
||||
|
||||
local function parsescalar(line, lines, indent)
|
||||
line = ltrim(line)
|
||||
line = gsub(line, '^%s*#.*$', '') -- comment only -> ''
|
||||
line = gsub(line, '^%s*', '') -- trim head spaces
|
||||
|
||||
if line == '' or line == '~' then
|
||||
return null
|
||||
end
|
||||
|
||||
local ts, _ = parsetimestamp(line)
|
||||
if ts then
|
||||
return ts
|
||||
end
|
||||
|
||||
local s, _ = parsestring(line)
|
||||
-- startswith quote ... string
|
||||
-- not startswith quote ... maybe string
|
||||
if s and (startswith(line, '"') or startswith(line, "'")) then
|
||||
return s
|
||||
end
|
||||
|
||||
if startswith('!', line) then -- unexpected tagchar
|
||||
error('unsupported line: '..line)
|
||||
end
|
||||
|
||||
if equalsline(line, '{}') then
|
||||
return {}
|
||||
end
|
||||
if equalsline(line, '[]') then
|
||||
return {}
|
||||
end
|
||||
|
||||
if startswith(line, '{') or startswith(line, '[') then
|
||||
return parseflowstyle(line, lines)
|
||||
end
|
||||
|
||||
if startswith(line, '|') or startswith(line, '>') then
|
||||
return parseblockstylestring(line, lines, indent)
|
||||
end
|
||||
|
||||
-- Regular unquoted string
|
||||
line = gsub(line, '%s*#.*$', '') -- trim tail comment
|
||||
local v = line
|
||||
if v == 'null' or v == 'Null' or v == 'NULL'then
|
||||
return null
|
||||
elseif v == 'true' or v == 'True' or v == 'TRUE' then
|
||||
return true
|
||||
elseif v == 'false' or v == 'False' or v == 'FALSE' then
|
||||
return false
|
||||
elseif v == '.inf' or v == '.Inf' or v == '.INF' then
|
||||
return math.huge
|
||||
elseif v == '+.inf' or v == '+.Inf' or v == '+.INF' then
|
||||
return math.huge
|
||||
elseif v == '-.inf' or v == '-.Inf' or v == '-.INF' then
|
||||
return -math.huge
|
||||
elseif v == '.nan' or v == '.NaN' or v == '.NAN' then
|
||||
return 0 / 0
|
||||
elseif sfind(v, '^[%+%-]?[0-9]+$') or sfind(v, '^[%+%-]?[0-9]+%.$')then
|
||||
return tonumber(v) -- : int
|
||||
elseif sfind(v, '^[%+%-]?[0-9]+%.[0-9]+$') then
|
||||
return tonumber(v)
|
||||
end
|
||||
return s or v
|
||||
end
|
||||
|
||||
local parsemap; -- : func
|
||||
|
||||
local function parseseq(line, lines, indent)
|
||||
local seq = setmetatable({}, types.seq)
|
||||
if line ~= '' then
|
||||
error()
|
||||
end
|
||||
while #lines > 0 do
|
||||
-- Check for a new document
|
||||
line = lines[1]
|
||||
if startswith(line, '---') then
|
||||
while #lines > 0 and not startswith(lines, '---') do
|
||||
tremove(lines, 1)
|
||||
end
|
||||
return seq
|
||||
end
|
||||
|
||||
-- Check the indent level
|
||||
local level = countindent(line)
|
||||
if level < indent then
|
||||
return seq
|
||||
elseif level > indent then
|
||||
error("found bad indenting in line: ".. line)
|
||||
end
|
||||
|
||||
local i, j = sfind(line, '%-%s+')
|
||||
if not i then
|
||||
i, j = sfind(line, '%-$')
|
||||
if not i then
|
||||
return seq
|
||||
end
|
||||
end
|
||||
local rest = ssub(line, j+1)
|
||||
|
||||
if sfind(rest, '^[^\'\"%s]*:') then
|
||||
-- Inline nested hash
|
||||
local indent2 = j
|
||||
lines[1] = string.rep(' ', indent2)..rest
|
||||
tinsert(seq, parsemap('', lines, indent2))
|
||||
elseif sfind(rest, '^%-%s+') then
|
||||
-- Inline nested seq
|
||||
local indent2 = j
|
||||
lines[1] = string.rep(' ', indent2)..rest
|
||||
tinsert(seq, parseseq('', lines, indent2))
|
||||
elseif isemptyline(rest) then
|
||||
tremove(lines, 1)
|
||||
if #lines == 0 then
|
||||
tinsert(seq, null)
|
||||
return seq
|
||||
end
|
||||
if sfind(lines[1], '^%s*%-') then
|
||||
local nextline = lines[1]
|
||||
local indent2 = countindent(nextline)
|
||||
if indent2 == indent then
|
||||
-- Null seqay entry
|
||||
tinsert(seq, null)
|
||||
else
|
||||
tinsert(seq, parseseq('', lines, indent2))
|
||||
end
|
||||
else
|
||||
-- - # comment
|
||||
-- key: value
|
||||
local nextline = lines[1]
|
||||
local indent2 = countindent(nextline)
|
||||
tinsert(seq, parsemap('', lines, indent2))
|
||||
end
|
||||
elseif rest then
|
||||
-- Array entry with a value
|
||||
tremove(lines, 1)
|
||||
tinsert(seq, parsescalar(rest, lines))
|
||||
end
|
||||
end
|
||||
return seq
|
||||
end
|
||||
|
||||
local function parseset(line, lines, indent)
|
||||
if not isemptyline(line) then
|
||||
error('not seq line: '..line)
|
||||
end
|
||||
local set = setmetatable({}, types.set)
|
||||
while #lines > 0 do
|
||||
-- Check for a new document
|
||||
line = lines[1]
|
||||
if startswith(line, '---') then
|
||||
while #lines > 0 and not startswith(lines, '---') do
|
||||
tremove(lines, 1)
|
||||
end
|
||||
return set
|
||||
end
|
||||
|
||||
-- Check the indent level
|
||||
local level = countindent(line)
|
||||
if level < indent then
|
||||
return set
|
||||
elseif level > indent then
|
||||
error("found bad indenting in line: ".. line)
|
||||
end
|
||||
|
||||
local i, j = sfind(line, '%?%s+')
|
||||
if not i then
|
||||
i, j = sfind(line, '%?$')
|
||||
if not i then
|
||||
return set
|
||||
end
|
||||
end
|
||||
local rest = ssub(line, j+1)
|
||||
|
||||
if sfind(rest, '^[^\'\"%s]*:') then
|
||||
-- Inline nested hash
|
||||
local indent2 = j
|
||||
lines[1] = string.rep(' ', indent2)..rest
|
||||
set[parsemap('', lines, indent2)] = true
|
||||
elseif sfind(rest, '^%s+$') then
|
||||
tremove(lines, 1)
|
||||
if #lines == 0 then
|
||||
tinsert(set, null)
|
||||
return set
|
||||
end
|
||||
if sfind(lines[1], '^%s*%?') then
|
||||
local indent2 = countindent(lines[1])
|
||||
if indent2 == indent then
|
||||
-- Null array entry
|
||||
set[null] = true
|
||||
else
|
||||
set[parseseq('', lines, indent2)] = true
|
||||
end
|
||||
end
|
||||
|
||||
elseif rest then
|
||||
tremove(lines, 1)
|
||||
set[parsescalar(rest, lines)] = true
|
||||
else
|
||||
error("failed to classify line: "..line)
|
||||
end
|
||||
end
|
||||
return set
|
||||
end
|
||||
|
||||
function parsemap(line, lines, indent)
|
||||
if not isemptyline(line) then
|
||||
error('not map line: '..line)
|
||||
end
|
||||
local map = setmetatable({}, types.map)
|
||||
while #lines > 0 do
|
||||
-- Check for a new document
|
||||
line = lines[1]
|
||||
if startswith(line, '---') then
|
||||
while #lines > 0 and not startswith(lines, '---') do
|
||||
tremove(lines, 1)
|
||||
end
|
||||
return map
|
||||
end
|
||||
|
||||
-- Check the indent level
|
||||
local level, _ = countindent(line)
|
||||
if level < indent then
|
||||
return map
|
||||
elseif level > indent then
|
||||
error("found bad indenting in line: ".. line)
|
||||
end
|
||||
|
||||
-- Find the key
|
||||
local key
|
||||
local s, rest = parsestring(line)
|
||||
|
||||
-- Quoted keys
|
||||
if s and startswith(rest, ':') then
|
||||
local sc = parsescalar(s, {}, 0)
|
||||
if sc and type(sc) ~= 'string' then
|
||||
key = sc
|
||||
else
|
||||
key = s
|
||||
end
|
||||
line = ssub(rest, 2)
|
||||
else
|
||||
error("failed to classify line: "..line)
|
||||
end
|
||||
|
||||
key = checkdupekey(map, key)
|
||||
line = ltrim(line)
|
||||
|
||||
if ssub(line, 1, 1) == '!' then
|
||||
-- ignore type
|
||||
local rh = ltrim(ssub(line, 3))
|
||||
local typename = smatch(rh, '^!?[^%s]+')
|
||||
line = ltrim(ssub(rh, #typename+1))
|
||||
end
|
||||
|
||||
if not isemptyline(line) then
|
||||
tremove(lines, 1)
|
||||
line = ltrim(line)
|
||||
map[key] = parsescalar(line, lines, indent)
|
||||
else
|
||||
-- An indent
|
||||
tremove(lines, 1)
|
||||
if #lines == 0 then
|
||||
map[key] = null
|
||||
return map;
|
||||
end
|
||||
if sfind(lines[1], '^%s*%-') then
|
||||
local indent2 = countindent(lines[1])
|
||||
map[key] = parseseq('', lines, indent2)
|
||||
elseif sfind(lines[1], '^%s*%?') then
|
||||
local indent2 = countindent(lines[1])
|
||||
map[key] = parseset('', lines, indent2)
|
||||
else
|
||||
local indent2 = countindent(lines[1])
|
||||
if indent >= indent2 then
|
||||
-- Null hash entry
|
||||
map[key] = null
|
||||
else
|
||||
map[key] = parsemap('', lines, indent2)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return map
|
||||
end
|
||||
|
||||
|
||||
-- : (list<str>)->dict
|
||||
local function parsedocuments(lines)
|
||||
lines = select(lines, function(s) return not isemptyline(s) end)
|
||||
|
||||
if sfind(lines[1], '^%%YAML') then tremove(lines, 1) end
|
||||
|
||||
local root = {}
|
||||
local in_document = false
|
||||
while #lines > 0 do
|
||||
local line = lines[1]
|
||||
-- Do we have a document header?
|
||||
local docright;
|
||||
if sfind(line, '^%-%-%-') then
|
||||
-- Handle scalar documents
|
||||
docright = ssub(line, 4)
|
||||
tremove(lines, 1)
|
||||
in_document = true
|
||||
end
|
||||
if docright then
|
||||
if (not sfind(docright, '^%s+$') and
|
||||
not sfind(docright, '^%s+#')) then
|
||||
tinsert(root, parsescalar(docright, lines))
|
||||
end
|
||||
elseif #lines == 0 or startswith(line, '---') then
|
||||
-- A naked document
|
||||
tinsert(root, null)
|
||||
while #lines > 0 and not sfind(lines[1], '---') do
|
||||
tremove(lines, 1)
|
||||
end
|
||||
in_document = false
|
||||
-- XXX The final '-+$' is to look for -- which ends up being an
|
||||
-- error later.
|
||||
elseif not in_document and #root > 0 then
|
||||
-- only the first document can be explicit
|
||||
error('parse error: '..line)
|
||||
elseif sfind(line, '^%s*%-') then
|
||||
-- An array at the root
|
||||
tinsert(root, parseseq('', lines, 0))
|
||||
elseif sfind(line, '^%s*[^%s]') then
|
||||
-- A hash at the root
|
||||
local level = countindent(line)
|
||||
tinsert(root, parsemap('', lines, level))
|
||||
else
|
||||
-- Shouldn't get here. @lines have whitespace-only lines
|
||||
-- stripped, and previous match is a line with any
|
||||
-- non-whitespace. So this clause should only be reachable via
|
||||
-- a perlbug where \s is not symmetric with \S
|
||||
|
||||
-- uncoverable statement
|
||||
error('parse error: '..line)
|
||||
end
|
||||
end
|
||||
if #root > 1 and Null.isnull(root[1]) then
|
||||
tremove(root, 1)
|
||||
return root
|
||||
end
|
||||
return root
|
||||
end
|
||||
|
||||
--- Parse yaml string into table.
|
||||
local function parse(source)
|
||||
local lines = {}
|
||||
for line in string.gmatch(source .. '\n', '(.-)\r?\n') do
|
||||
tinsert(lines, line)
|
||||
end
|
||||
|
||||
local docs = parsedocuments(lines)
|
||||
if #docs == 1 then
|
||||
return docs[1]
|
||||
end
|
||||
|
||||
return docs
|
||||
end
|
||||
|
||||
return {
|
||||
version = 0.1,
|
||||
parse = parse,
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
local function colorToYaml(c)
|
||||
if not c then return nil end
|
||||
-- Check if it's a color object with hex method, otherwise use raw values
|
||||
if type(c) == "table" then
|
||||
if c.toHex then return c:toHex() end
|
||||
-- Normalise to 0-255 for readability
|
||||
local r = c[1] or 0
|
||||
local g = c[2] or 0
|
||||
local b = c[3] or 0
|
||||
local a = c[4]
|
||||
if r <= 1 and g <= 1 and b <= 1 then
|
||||
r, g, b = math.floor(r*255), math.floor(g*255), math.floor(b*255)
|
||||
if a then a = math.floor(a*255) end
|
||||
end
|
||||
if a and a < 255 then
|
||||
return string.format("[%d, %d, %d, %d]", r, g, b, a)
|
||||
end
|
||||
return string.format("[%d, %d, %d]", r, g, b)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function dualDimToYaml(obj)
|
||||
local dd = obj.dualDim
|
||||
local fields = {}
|
||||
local op = dd.offset.pos
|
||||
local os = dd.offset.size
|
||||
local sp = dd.scale.pos
|
||||
local ss = dd.scale.size
|
||||
|
||||
if op.x ~= 0 then fields[#fields+1] = {"x", op.x} end
|
||||
if op.y ~= 0 then fields[#fields+1] = {"y", op.y} end
|
||||
if os.x ~= 0 then fields[#fields+1] = {"w", os.x} end
|
||||
if os.y ~= 0 then fields[#fields+1] = {"h", os.y} end
|
||||
if sp.x ~= 0 then fields[#fields+1] = {"sx", sp.x} end
|
||||
if sp.y ~= 0 then fields[#fields+1] = {"sy", sp.y} end
|
||||
if ss.x ~= 0 then fields[#fields+1] = {"sw", ss.x} end
|
||||
if ss.y ~= 0 then fields[#fields+1] = {"sh", ss.y} end
|
||||
return fields
|
||||
end
|
||||
|
||||
local bit = require("bit")
|
||||
local band = bit.band
|
||||
local frame, image, text, box, video, button, anim = 0, 1, 2, 4, 8, 16, 32
|
||||
|
||||
local function resolveTypeName(typ)
|
||||
-- Match in specificity order (combined types first)
|
||||
if typ == text + box then return "textbox" end
|
||||
if typ == text + button then return "button" end
|
||||
if typ == text + frame then return "label" end
|
||||
if typ == image + frame then return "image" end
|
||||
-- image+button uses frame internally in this lib
|
||||
-- (newImageButton sets type = image+frame but adds cursor behaviour)
|
||||
-- We detect image buttons by checking for cursor handler presence;
|
||||
-- as a fallback we use "image" and the loader will still reconstruct it.
|
||||
if band(typ, video) == video then return "video" end
|
||||
if band(typ, image) == image then return "image" end
|
||||
if typ == frame then return "frame" end
|
||||
return "frame" -- safe fallback
|
||||
end
|
||||
|
||||
local alignNames = {[0]="center", [1]="left", [2]="right"}
|
||||
local formNames = {
|
||||
[1] = "rectangle",
|
||||
[2] = "circle",
|
||||
[3] = "arc",
|
||||
}
|
||||
|
||||
-- YAML emitter — produces clean, human-readable YAML without a library dep.
|
||||
local function emit(val, indent, visited)
|
||||
indent = indent or 0
|
||||
visited = visited or {}
|
||||
local pad = string.rep(" ", indent)
|
||||
local t = type(val)
|
||||
|
||||
if t == "boolean" then return tostring(val) end
|
||||
if t == "number" then
|
||||
-- Avoid scientific notation for small floats
|
||||
if val == math.floor(val) then return string.format("%d", val) end
|
||||
return string.format("%.6g", val)
|
||||
end
|
||||
if t == "string" then
|
||||
-- Quote if contains special YAML chars or is empty
|
||||
if val == "" or val:match("^[%s#&*!|>'\"%[%]{},?:-]") or val:match("[\n\r]") then
|
||||
-- Escape inner quotes, wrap in double quotes
|
||||
return '"' .. val:gsub('"', '\\"'):gsub("\n", "\\n") .. '"'
|
||||
end
|
||||
return val
|
||||
end
|
||||
if t ~= "table" then return tostring(val) end
|
||||
|
||||
-- Cycle guard
|
||||
if visited[val] then return '"<cycle>"' end
|
||||
visited[val] = true
|
||||
|
||||
-- Detect plain array (sequential integer keys starting at 1)
|
||||
local isArray = true
|
||||
local maxN = 0
|
||||
for k, _ in pairs(val) do
|
||||
if type(k) ~= "number" or k ~= math.floor(k) or k < 1 then
|
||||
isArray = false
|
||||
break
|
||||
end
|
||||
if k > maxN then maxN = k end
|
||||
end
|
||||
if isArray and maxN ~= #val then isArray = false end
|
||||
|
||||
local lines = {}
|
||||
|
||||
if isArray then
|
||||
-- Inline short numeric/string arrays on one line
|
||||
local allScalar = true
|
||||
for _, v in ipairs(val) do
|
||||
if type(v) == "table" then allScalar = false; break end
|
||||
end
|
||||
if allScalar and #val <= 6 then
|
||||
local parts = {}
|
||||
for _, v in ipairs(val) do parts[#parts+1] = emit(v, 0, visited) end
|
||||
visited[val] = nil
|
||||
return "[" .. table.concat(parts, ", ") .. "]"
|
||||
end
|
||||
for _, v in ipairs(val) do
|
||||
local rendered = emit(v, indent + 1, visited)
|
||||
if type(v) == "table" then
|
||||
lines[#lines+1] = pad .. "-\n" .. rendered
|
||||
else
|
||||
lines[#lines+1] = pad .. "- " .. rendered
|
||||
end
|
||||
end
|
||||
else
|
||||
for _, pair in ipairs(val) do
|
||||
local k, v = pair[1], pair[2]
|
||||
local rendered = emit(v, indent + 1, visited)
|
||||
if type(v) == "table" and #v > 0 and type(v[1]) == "table" then
|
||||
-- Nested block (list of pairs = mapping, or list of items)
|
||||
lines[#lines+1] = pad .. k .. ":\n" .. rendered
|
||||
elseif type(v) == "table" and type(v[1]) ~= "table" then
|
||||
-- Inline array
|
||||
lines[#lines+1] = pad .. k .. ": " .. rendered
|
||||
else
|
||||
lines[#lines+1] = pad .. k .. ": " .. rendered
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
visited[val] = nil
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Main export function
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
local function guiToYaml(obj, opts)
|
||||
--[[
|
||||
opts = {
|
||||
indent = 0, -- starting indent level
|
||||
skipDefaults = true, -- omit fields that equal their default values
|
||||
includeChildren = true, -- recurse into children
|
||||
eventNames = {}, -- map of connection object -> string name
|
||||
-- e.g. {[obj.OnPressed] = "handlePress"}
|
||||
}
|
||||
--]]
|
||||
opts = opts or {}
|
||||
local skipDef = opts.skipDefaults ~= false -- default true
|
||||
local inclChild = opts.includeChildren ~= false -- default true
|
||||
local indent = opts.indent or 0
|
||||
local evNames = opts.eventNames or {}
|
||||
|
||||
-- Ordered list of {key, value} pairs — order controls YAML output order
|
||||
local fields = {}
|
||||
local function add(k, v)
|
||||
if v == nil then return end
|
||||
if skipDef then
|
||||
-- Skip booleans that match common defaults
|
||||
if k == "visible" and v == true then return end
|
||||
if k == "active" and v == true then return end
|
||||
if k == "visibility" and v == 1 then return end
|
||||
if k == "draw-border" and v == true then return end
|
||||
if k == "rotation" and v == 0 then return end
|
||||
if k == "align" and v == "left" then return end
|
||||
if k == "text-visibility" and v == 1 then return end
|
||||
if k == "image-visibility" and v == 1 then return end
|
||||
if k == "video-visibility" and v == 1 then return end
|
||||
if k == "scale-x" and v == 1 then return end
|
||||
if k == "scale-y" and v == 1 then return end
|
||||
end
|
||||
fields[#fields+1] = {k, v}
|
||||
end
|
||||
|
||||
-- ── Type ──────────────────────────────────────────────────────────
|
||||
add("type", resolveTypeName(obj.type))
|
||||
|
||||
-- ── Dual dimensions ───────────────────────────────────────────────
|
||||
for _, pair in ipairs(dualDimToYaml(obj)) do
|
||||
add(pair[1], pair[2])
|
||||
end
|
||||
|
||||
-- ── Shared appearance ─────────────────────────────────────────────
|
||||
local col = colorToYaml(obj.color)
|
||||
if col and col ~= "[142, 141, 141]" then -- skip library default grey
|
||||
add("color", col)
|
||||
end
|
||||
local bcol = colorToYaml(obj.borderColor)
|
||||
if bcol and bcol ~= "[0, 0, 0]" then
|
||||
add("border-color", bcol)
|
||||
end
|
||||
add("draw-border", obj.drawBorder)
|
||||
add("visible", obj.visible)
|
||||
add("active", obj.active)
|
||||
if obj.visibility ~= 1 then add("visibility", obj.visibility) end
|
||||
if obj.rotation ~= 0 then add("rotation", obj.rotation) end
|
||||
|
||||
-- ── Tag / tags ────────────────────────────────────────────────────
|
||||
if obj.__tag then add("tag", obj.__tag) end
|
||||
if obj.tags then
|
||||
local tagList = {}
|
||||
for t, _ in pairs(obj.tags) do tagList[#tagList+1] = t end
|
||||
if #tagList > 0 then add("tags", tagList) end
|
||||
end
|
||||
|
||||
-- ── Form factor ───────────────────────────────────────────────────
|
||||
local ff = obj.formFactor or 1
|
||||
if ff ~= 1 then -- skip default "rectangle"
|
||||
add("form", formNames[ff] or "rectangle")
|
||||
if obj.__radius then add("radius", obj.__radius) end
|
||||
if obj.segments then add("segments", obj.segments) end
|
||||
if ff == 3 then
|
||||
add("arc-type", obj.arcType or "open")
|
||||
add("angle-start", obj.__angleS)
|
||||
add("angle-end", obj.__angleE)
|
||||
end
|
||||
end
|
||||
|
||||
-- ── Roundness ─────────────────────────────────────────────────────
|
||||
if obj.roundness then
|
||||
local r = obj.roundness
|
||||
if r == true then
|
||||
-- generic — emit the rx/ry/segments triple
|
||||
add("roundness", {obj.__rx or 5, obj.__ry or 5, obj.__segments or 30})
|
||||
elseif type(r) == "string" then
|
||||
add("roundness", r) -- "top" or "bottom"
|
||||
end
|
||||
end
|
||||
|
||||
-- ── Behaviour flags ───────────────────────────────────────────────
|
||||
if obj.clipDescendants then add("clip-descendants", true) end
|
||||
if obj.square then add("square", obj.square) end
|
||||
|
||||
-- ── Text-type fields ──────────────────────────────────────────────
|
||||
if band(obj.type, text) == text then
|
||||
if obj.text and obj.text ~= "" then add("text", obj.text) end
|
||||
|
||||
local al = alignNames[obj.align]
|
||||
add("align", al)
|
||||
|
||||
local tc = colorToYaml(obj.textColor)
|
||||
if tc and tc ~= "[0, 0, 0]" then add("text-color", tc) end
|
||||
if obj.textVisibility ~= 1 then add("text-visibility", obj.textVisibility) end
|
||||
|
||||
if obj.textScaleX ~= 1 or obj.textScaleY ~= 1 then
|
||||
add("text-scale", {obj.textScaleX, obj.textScaleY})
|
||||
end
|
||||
if obj.textOffsetX ~= 0 or obj.textOffsetY ~= 0 then
|
||||
add("text-offset", {obj.textOffsetX, obj.textOffsetY})
|
||||
end
|
||||
if obj.textShearingFactorX ~= 0 or obj.textShearingFactorY ~= 0 then
|
||||
add("text-shear", {obj.textShearingFactorX, obj.textShearingFactorY})
|
||||
end
|
||||
|
||||
-- Font: emit as {file, size} when a file path is known
|
||||
if obj.font then
|
||||
if obj.fontFile then
|
||||
add("font", {{"file", obj.fontFile}, {"size", obj.font:getHeight()}})
|
||||
else
|
||||
add("font", obj.font:getHeight())
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ── Image-type fields ─────────────────────────────────────────────
|
||||
if band(obj.type, image) == image and band(obj.type, video) ~= video then
|
||||
-- Source path is stored via getSource()
|
||||
local src = obj:getSource and obj:getSource()
|
||||
if src then add("source", src) end
|
||||
|
||||
if obj.scaleX ~= 1 then add("scale-x", obj.scaleX) end
|
||||
if obj.scaleY ~= 1 then add("scale-y", obj.scaleY) end
|
||||
|
||||
local ic = colorToYaml(obj.imageColor)
|
||||
if ic and ic ~= "[255, 255, 255]" then add("image-color", ic) end
|
||||
if obj.imageVisibility and obj.imageVisibility ~= 1 then
|
||||
add("image-visibility", obj.imageVisibility)
|
||||
end
|
||||
end
|
||||
|
||||
-- ── Video-type fields ─────────────────────────────────────────────
|
||||
if band(obj.type, video) == video then
|
||||
local src = obj:getSource and obj:getSource()
|
||||
if src then add("source", src) end
|
||||
|
||||
local vc = colorToYaml(obj.videoColor)
|
||||
if vc and vc ~= "[255, 255, 255]" then add("video-color", vc) end
|
||||
if obj.videoVisibility and obj.videoVisibility ~= 1 then
|
||||
add("video-visibility", obj.videoVisibility)
|
||||
end
|
||||
if obj.audiosource then
|
||||
add("volume", obj.audiosource:getVolume())
|
||||
end
|
||||
if obj.playing then add("autoplay", true) end
|
||||
end
|
||||
|
||||
-- ── Events ────────────────────────────────────────────────────────
|
||||
-- We can only serialise events when the caller supplies a name map.
|
||||
-- Otherwise we silently skip them (can't decompile closures).
|
||||
local connMap = {
|
||||
["on-pressed"] = obj.OnPressed,
|
||||
["on-released"] = obj.OnReleased,
|
||||
["on-released-outer"] = obj.OnReleasedOuter,
|
||||
["on-pressed-outer"] = obj.OnPressedOuter,
|
||||
["on-enter"] = obj.OnEnter,
|
||||
["on-exit"] = obj.OnExit,
|
||||
["on-moved"] = obj.OnMoved,
|
||||
["on-drag-start"] = obj.OnDragStart,
|
||||
["on-dragging"] = obj.OnDragging,
|
||||
["on-drag-end"] = obj.OnDragEnd,
|
||||
["on-wheel"] = obj.OnWheelMoved,
|
||||
["on-size-changed"] = obj.OnSizeChanged,
|
||||
["on-position-changed"] = obj.OnPositionChanged,
|
||||
["on-destroy"] = obj.OnDestroy,
|
||||
["on-load"] = obj.OnLoad,
|
||||
["on-return"] = obj.OnReturn,
|
||||
}
|
||||
for yamlKey, conn in pairs(connMap) do
|
||||
if conn and evNames[conn] then
|
||||
add(yamlKey, evNames[conn])
|
||||
end
|
||||
end
|
||||
|
||||
-- ── Children ──────────────────────────────────────────────────────
|
||||
if inclChild and obj.children and #obj.children > 0 then
|
||||
local childDefs = {}
|
||||
for _, child in ipairs(obj.children) do
|
||||
-- Recurse, collect as ordered-pair tables for the emitter
|
||||
local childFields = guiToYaml(child, {
|
||||
skipDefaults = opts.skipDefaults,
|
||||
includeChildren = opts.includeChildren,
|
||||
eventNames = opts.eventNames,
|
||||
_returnRaw = true, -- internal: return fields table, not string
|
||||
})
|
||||
childDefs[#childDefs+1] = childFields
|
||||
end
|
||||
fields[#fields+1] = {"children", childDefs}
|
||||
end
|
||||
|
||||
-- Internal mode: return the raw ordered-pair table for parent to embed
|
||||
if opts._returnRaw then return fields end
|
||||
|
||||
return emit(fields, indent)
|
||||
end
|
||||
|
||||
return guiToYaml
|
||||
Reference in New Issue
Block a user