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 '""' 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