jeopardy/gui/yaml/toyaml.lua
2026-05-06 19:39:08 -07:00

362 lines
16 KiB
Lua

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