removed folder

This commit is contained in:
Ryan Ward 2026-05-30 21:28:34 -07:00
parent d7aaab4589
commit 6419e0f738
24 changed files with 1 additions and 10881 deletions

2
.gitmodules vendored
View File

@ -1,3 +1,3 @@
[submodule "multi"]
path = multi
url = https://github.com/rayaman/multi.git
url = https://github.com/rayaman/multi.git

View File

@ -1,72 +0,0 @@
# GuiManager
This library due to the changes in love2d. Too many things are broken and instead of doing patch work, I've decided to do a total rewrite. Also I'll be able to make use of the new multi manager features and build a better library from the ground up.
Core Objects:
- ~~Frame~~ ✔️
- Text:
- ~~Label~~ ✔️
- ~~Box~~ ✔️
- ~~Button~~ ✔️
- utf8 support with textbox (Forgot about this, will have to rework some things)
- Image:
- ~~Label~~ ✔️
- ~~Button~~ ✔️
- Animation
- ~~Video~~ ✔️
Events:
- Mouse Events
- ~~Enter~~ ✔️
- ~~Exit~~ ✔️
- ~~Pressed~~ ✔️
- ~~Released~~ ✔️
- ~~Moved~~ ✔️
- ~~WheelMoved~~ ✔️
- ~~DragStart~~ ✔️
- ~~Dragging~~ ✔️
- ~~DragEnd~~ ✔️
- Keyboard Events
- ~~Hotkey~~ ✔️ Refer to [KeyConstants](https://love2d.org/wiki/KeyConstant) wiki page
- Some default hotkeys have been added:
- ~~(conn)gui.HotKeys.OnSelectAll~~ ✔️ `Ctrl + A`
- ~~(conn)gui.HotKeys.OnCopy~~ ✔️ `Ctrl + C`
- ~~(conn)gui.HotKeys.OnPaste~~ ✔️ `Ctrl + V`
- ~~(conn)gui.HotKeys.OnUndo~~ ✔️ `Ctrl + Z`
- ~~(conn)gui.HotKeys.OnRedo~~ ✔️ `Ctrl + Y, Ctrl + Shift + Z`
- Other Events
- ~~OnUpdate~~ ✔️
- ~~OnDraw~~ ✔️
Polish:
- ~~gui:newCheckbox()~~
- ~~gui:isOnScreen()~~
- ~~gui:newRadioGroup()~~
- gui:newSlider()
- ~~gui:newProgressBar()~~
- gui:newTooltip()
- gui:newTextArea()
- TODO: selection, hotkeys
- gui:newListFrame()
- gui:newGridFrame()
Better Transistions:
- ease.easeIn(start, stop, time) -- accelerates from start
- ease.easeOut(start, stop, time) -- decelerates into stop
- ease.easeInOut(start, stop, time) -- smooth S-curve
- ease.easeInCubic(...) -- more aggressive acceleration
- ease.easeOutCubic(...) -- more aggressive deceleration
- ease.bounce(start, stop, time) -- bounces at the end
- ease.elastic(start, stop, time) -- overshoots then settles
- ease.back(start, stop, time) -- slight pull-back before moving
Focus Management:
- gui.focus.set(element) -- programmatically set focus
- gui.focus.get() -- returns currently focused element (or nil)
- gui.focus.clear() -- clear focus (no element focused)
- gui.focus.setTabOrder(list) -- set a list of elements for Tab navigation
- gui.focus.tabNext() -- focus the next element in the tab order
- gui.focus.tabPrev() -- focus the previous element
Z-index Enhancments:
- gui:setLayer(n)
- gui:getLayer()

View File

@ -1,249 +0,0 @@
local gui = require("gui")
local theme = require("gui.core.theme")
local color = require("gui.core.color")
local multi, thread = require("multi"):init()
local mediaProc = gui:newProcessor()
local miscProc = gui:newProcessor()
local function noOf(sx,sy,sw,sh)
return nil,nil,nil,nil,sx,sy,sw,sh
end
function gui:newVideoPlayer(source, x, y, w, h, sx, sy, sw, sh, draggable, th)
local window = self:newWindow(x, y, w, h, sx, sy, sw, sh, "", draggable, th or theme:new({
primary = "#000000",
primaryDark = "#10465c",
primaryText = "#ffffff"
}))
local video = window:newVideo(source, 0, 0, 0, 0, 0, .05, 1, .75)
local length = video:getDuration()
local play_pause = window:newImageButton("gui/assets/play.png",0,0,0,0,.45,.86,0,.14)
local seek = window:newProgressBar(0,0,0,0,0,.8,1,.05,length*100,0)
seek.OnProgressUpdated(function(self,_,value, drag)
if drag then
local status = video:isPlaying()
video:seek(value/100)
if status then
video:play()
else
video:stop()
end
end
end)
seek:isClickable(true)
play_pause.square = "h"
play_pause.isPaused = true
play_pause:OnReleased(function(self)
if self.isPaused then
self:setImage("gui/assets/pause.png")
video:play()
else
self:setImage("gui/assets/play.png")
video:pause()
end
self.isPaused = not self.isPaused
end)
mediaProc:newThread(function()
while true do
thread.hold(function() return video:isPlaying() end)
local p = video:tell()
seek:update(p*100)
end
end)
function window:seek(...)
video.video:seek(...)
end
function window:play()
video:play()
end
function window:stop()
video:stop()
end
window.OnClose(function()
video:pause()
end)
return window
end
function gui:newCheckbox(label, x, y, size, sx, sy, checked)
local checkbox = self:newFrame(x, y, size, size, sx, sy)
checkbox.color = color.black
local border = checkbox:newVisualFrame(noOf(.1,.1,.8,.8))
border.color = color.white
local toggle = border:newFrame(noOf(.3,.3,.4,.4))
toggle.color = color.black
toggle.visible = false
checkbox:OnReleased(function()
checkbox:check(not toggle.visible)
end)
if label ~= "" then
local text = checkbox:newTextLabel(label, noOf(1.25,0,15,1))
text:OnUpdate(function()
text:centerFont()
end)
text:setFont(size-2)
text.visibility = 0
end
function checkbox:check(value)
toggle.visible = value
self.OnChanged:Fire(value)
end
function checkbox:isChecked()
return toggle.visible
end
function checkbox:getLabel()
return label or ""
end
checkbox.OnChanged = multi:newConnection()
return checkbox
end
function gui:newRadioGroup(options, x, y, sx, sy, size)
local group = {}
local rg = self:newFrame()
local selected
rg.OnSelectionChanged = multi:newConnection()
for i,v in ipairs(options or {}) do
table.insert(group,self:newCheckbox(tostring(v),x,y+((i-1)*size+((options.padding or 0)*(i-1))),size,sx,sy))
end
gui.apply({
OnReleased=function(self)
gui.apply({check={false}},unpack(group))
self:check(true)
if selected ~= self then
rg.OnSelectionChanged:Fire(rg, self)
end
selected = self
end,
},unpack(group))
function rg:getSelectedOption()
return selected
end
return rg
end
function gui:newProgressBar(x, y, w, h, sx, sy, sw, sh, count, value)
local value = value or 0
local count = count or 100
local progressbar = self:newFrame(x,y,w,h,sx,sy,sw,sh)
local fillframe = progressbar:newFrame(2,2,-4,-4,0,0,1,1)
local fill = fillframe:newFrame(noOf(0, 0, 1, 1))
local percentDisplay = fillframe:newTextLabel("",noOf(0,0,1,1))
percentDisplay.align = gui.ALIGN_CENTER
percentDisplay.textColor = color.new("#CC5500")
fillframe.visibility = 0
progressbar.color = color.new("#000000")
fill.color = color.new("#ffffff")
progressbar.fillframe = fillframe
progressbar.fill = fill
progressbar.display = percentDisplay
progressbar.OnProgressUpdated = multi:newConnection()
percentDisplay.visibility = 0
local displayPercent = false
function progressbar:showPercent(bool)
displayPercent = bool
if bool then
miscProc:newThread(function()
thread.skip(2)
_,_,_h = percentDisplay:getAbsolutes()
percentDisplay:setFont(math.floor(h/1.3))
self:update()
end)
end
end
function progressbar:isClickable(bool)
fillframe:respectHierarchy(not bool)
-- Makes the bar updatable by clicking on it
fillframe:enableDragging(bool and gui.MOUSE_PRIMARY)
end
local calcFunc = function(self, dx, dy, x, y, istouch)
local sx, sy, sw, sh = self:getAbsolutes()
if x >= sx and x <= sx + sw then
progressbar:update((((x - sx)/sw) * count), true)
end
end
fillframe:OnDragStart(calcFunc)
fillframe.OnDragging(calcFunc)
fillframe.OnPressed(function(self, x, y, dx, dy, istouch)
calcFunc(self, dx, dy, x, y, istouch)
end)
function progressbar:update(v, drag)
v = v or value
if v > count then v = count end
if v < 0 then v = 0 end
local percent = value/count
fill:setDualDim(noOf(nil,nil,percent))
if displayPercent then
percentDisplay.text = math.floor((percent*100)+.5).. "%"
percentDisplay:centerFont()
end
value = v
self.OnProgressUpdated:Fire(self, percent, value, drag)
end
function progressbar:add(n)
if value >= count then
return
end
value = value + n
self:update(value)
end
function progressbar:sub(n)
if value <= 0 then
return
end
value = value - n
self:update(value)
end
function progressbar:max()
self:update(count)
end
function progressbar:min()
self:update(0)
end
function progressbar:half()
self:update(math.floor(count/2))
end
function progressbar:getPercent()
return math.floor(((value/count)*100)+.5)
end
function progressbar:getValue()
return value
end
progressbar:update(value)
-- to change colors and modify main components
return progressbar, fill, percentDisplay, fillframe
end

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +0,0 @@
-- Addons modify the gui interface directly and do not return anything.
require("gui.addons.extensions")
require("gui.addons.system")

View File

@ -1,955 +0,0 @@
local multi, thread = require("multi"):init()
local gui = require("gui")
local theme = require("gui.core.theme")
local color = require("gui.core.color")
local TM_THEME = theme:new({
primary = "#124559",
primaryDark = "#01161E",
primaryText = "#AEC3B0"
})
local default_theme = TM_THEME
-- ── layout constants ──────────────────────────────────────────────────────────
-- Columns: Indent+Name, State, Status, Uptime, Priority, Pause, Kill
-- Name shrunk so data columns have room to breathe
local COL_WIDTHS = { 300, 130, 120, 100, 130, 100, 100, 70 }
local COL_KEYS = { "name", "kind", "state", "status", "uptime", "priority", "pause", "kill" }
local COL_LABELS = { "Name", "Type", "State", "Status", "Uptime", "Priority", "", "" }
local SORT_COLS = { "name", "kind", "state", "status", "uptime", "priority" }
local ROW_H = 28
local COL_X = {}
do
local acc = 0
for i, w in ipairs(COL_WIDTHS) do
COL_X[i] = acc
acc = acc + w
end
end
local TOTAL_W = COL_X[#COL_X] + COL_WIDTHS[#COL_WIDTHS] -- 700
-- ── thread state names ────────────────────────────────────────────────────────
local STATE_NAMES = {
[1] = "holding",
[2] = "sleeping",
[3] = "hold+time",
[4] = "skipping",
[5] = "hold+cyc",
[6] = "yielding",
[7] = "running",
}
local function fmtState(obj)
if obj._isPaused then return "paused" end
local t = obj.task
if t == nil then return "running" end
return STATE_NAMES[t] or ("state:"..tostring(t))
end
local PRIORITY_NAMES = {
[1] = "Core",
[4] = "V.High",
[16] = "High",
[64] = "Above",
[256] = "Normal",
[1024] = "Below",
[4096] = "Low",
[16384] = "V.Low",
[65536] = "Idle",
}
local PRIORITY_CYCLE = { 1, 4, 16, 64, 256, 1024, 4096, 16384, 65536 }
local function fmtPriority(obj)
local p = rawget(obj, "Priority") or rawget(obj, "priority")
if not p then return "n/a" end
return PRIORITY_NAMES[p] or tostring(p)
end
local function nextPriority(current)
for i, v in ipairs(PRIORITY_CYCLE) do
if v == current then
return PRIORITY_CYCLE[(i % #PRIORITY_CYCLE) + 1]
end
end
return 256
end
-- ── helpers ───────────────────────────────────────────────────────────────────
local function fmtUptime(secs)
secs = math.floor(secs)
if secs < 60 then return secs .. "s" end
if secs < 3600 then return math.floor(secs/60).."m "..(secs%60).."s" end
return math.floor(secs/3600).."h "..math.floor((secs%3600)/60).."m"
end
local function nowClock() return os.clock() end
-- ── data collection ───────────────────────────────────────────────────────────
-- Returns a flat list of rows with depth so the UI can indent names.
local function collectTasks()
local rows = {}
local stats = multi:getStats()
local function addProc(fullname, proc, depth)
-- Processor header row
rows[#rows+1] = {
isProc = true,
depth = depth,
name = proc.name or fullname,
fullname = fullname,
kind = "processor",
conns = proc.connections or 0,
subs = proc.subscriptions or 0,
}
-- Tasks (Mainloop actors)
local tasks = proc.tasks or {}
for _, task in pairs(tasks) do
if not task.isProcessThread then
rows[#rows+1] = {
isProc = false,
depth = depth + 1,
name = task:getName() or "?",
fullname = fullname,
kind = tostring(task.Type),
state = fmtState(task),
active = not task:isPaused(),
uptime = nowClock() - (task.UPTIME or nowClock()),
priority = rawget(task, "Priority") or 256,
fmtPri = fmtPriority(task),
obj = task,
isThread = false,
}
end
end
-- Threads
local threads = proc.threads or {}
for _, th in pairs(threads) do
rows[#rows+1] = {
isProc = false,
depth = depth + 1,
name = th:getName() or "?",
fullname = fullname,
kind = tostring(th.Type),
state = fmtState(th),
active = not th:isPaused(),
uptime = nowClock() - (th.UPTIME or nowClock()),
priority = rawget(th, "Priority") or 256,
fmtPri = fmtPriority(th),
obj = th,
isThread = true,
}
end
end
-- Root first, then sub-processors sorted
if stats["root"] then
addProc("root", stats["root"], 0)
end
local procNames = {}
for k in pairs(stats) do
if k ~= "root" then procNames[#procNames+1] = k end
end
table.sort(procNames)
for _, k in ipairs(procNames) do
addProc(k, stats[k], 1)
end
return rows
end
function gui:newScrollFrame(x, y, w, h, sx, sy, sw, sh)
local viewport = self:newFrame(x, y, w, h, sx, sy, sw, sh)
viewport.clipDescendants = true
viewport.drawBorder = false
local content = viewport:newFrame(0, 0, w, 0)
content.drawBorder = false
local scrollY = 0
local maxScrollY = 0
local scrollX = 0
local maxScrollX = 0
local SCROLL_SPEED = 40
local SCROLL_BAR_W = 8
local vBar = viewport:newFrame(-SCROLL_BAR_W, 0, SCROLL_BAR_W, 0, 1, 0, 0, 1)
vBar.color = {0.3, 0.3, 0.3}
vBar.drawBorder = false
vBar.visible = false
local vThumb = vBar:newFrame(0, 0, SCROLL_BAR_W, 40)
vThumb.color = {0.6, 0.6, 0.6}
vThumb.drawBorder = false
local hBar = viewport:newFrame(0, -SCROLL_BAR_W, 0, SCROLL_BAR_W, 0, 1, 1)
hBar.color = {0.3, 0.3, 0.3}
hBar.drawBorder = false
hBar.visible = false
local hThumb = hBar:newFrame(0, 0, 40, SCROLL_BAR_W)
hThumb.color = {0.6, 0.6, 0.6}
hThumb.drawBorder = false
local applying = false
local function getViewSize()
local _, _, vw, vh = viewport:getAbsolutes()
return vw, vh
end
local function clamp(val, lo, hi)
return math.max(lo, math.min(hi, val))
end
local function updateScrollbars()
if applying then return end
local vw, vh = getViewSize()
local _, _, cw, ch = content:getAbsolutes()
maxScrollY = math.max(0, ch - vh)
if maxScrollY > 0 then
vBar.visible = true
local thumbH = math.max(20, vh * (vh / ch))
local thumbY = (scrollY / maxScrollY) * (vh - thumbH)
vThumb:setDualDim(0, thumbY, SCROLL_BAR_W, thumbH)
else
vBar.visible = false
scrollY = 0
end
maxScrollX = math.max(0, cw - vw)
if maxScrollX > 0 then
hBar.visible = true
local thumbW = math.max(20, vw * (vw / cw))
local thumbX = (scrollX / maxScrollX) * (vw - thumbW)
hThumb:setDualDim(thumbX, 0, thumbW, SCROLL_BAR_W)
else
hBar.visible = false
scrollX = 0
end
end
local function applyScroll()
if applying then return end
applying = true
scrollY = clamp(scrollY, 0, maxScrollY)
scrollX = clamp(scrollX, 0, maxScrollX)
content:setDualDim(-scrollX, -scrollY)
updateScrollbars()
applying = false
end
viewport.OnWheelMoved(function(x, y)
scrollY = scrollY - y * SCROLL_SPEED
applyScroll()
end)
vThumb:enableDragging(gui.MOUSE_PRIMARY)
vThumb.OnDragging(function(self, dx, dy)
local _, vh = getViewSize()
local _, _, _, thumbH = vThumb:getAbsolutes()
local trackH = vh - thumbH
if trackH <= 0 then return end
scrollY = scrollY + dy * (maxScrollY / trackH)
applyScroll()
end)
hThumb:enableDragging(gui.MOUSE_PRIMARY)
hThumb.OnDragging(function(self, dx, dy)
local vw, _ = getViewSize()
local _, _, thumbW = hThumb:getAbsolutes()
local trackW = vw - thumbW
if trackW <= 0 then return end
scrollX = scrollX + dx * (maxScrollX / trackW)
applyScroll()
end)
content.OnSizeChanged(function()
if applying then return end
local _, _, cw, ch = content:getAbsolutes()
local vw, vh = getViewSize()
maxScrollY = math.max(0, ch - vh)
maxScrollX = math.max(0, cw - vw)
scrollY = clamp(scrollY, 0, maxScrollY)
scrollX = clamp(scrollX, 0, maxScrollX)
updateScrollbars()
end)
viewport.OnSizeChanged(function()
if applying then return end
applyScroll()
end)
function content:scrollTo(sy, sx)
scrollY = sy or scrollY
scrollX = sx or scrollX
applyScroll()
end
function content:scrollBy(dy, dx)
scrollY = scrollY + (dy or 0)
scrollX = scrollX + (dx or 0)
applyScroll()
end
function content:scrollToBottom() scrollY = maxScrollY; applyScroll() end
function content:scrollToTop() scrollY = 0; applyScroll() end
function content:setScrollSpeed(speed) SCROLL_SPEED = speed end
function content:getScrollPos() return scrollX, scrollY end
function content:getMaxScroll() return maxScrollX, maxScrollY end
local _baseSDD = content.setDualDim
function content:setContentSize(cw, ch)
_baseSDD(self, nil, nil, cw or select(3, self:getAbsolutes()), ch)
applyScroll()
end
local _baseDestroy = viewport.destroy
function viewport:destroy()
content:destroy()
_baseDestroy(self)
end
applyScroll()
return content
end
-- ── window constructor (unchanged from original) ──────────────────────────────
local windowCount = 0
function gui:newWindow(x, y, w, h, sx, sy, sw, sh, text, draggable, theme)
local process = gui:newProcessor(text or "window_"..windowCount)
windowCount = windowCount + 1
local parent = self
local pointer = love.mouse.getCursor()
local sizewe = love.mouse.getSystemCursor("sizewe")
local sizens = love.mouse.getSystemCursor("sizens")
local sizenesw = love.mouse.getSystemCursor("sizenesw")
local sizenwse = love.mouse.getSystemCursor("sizenwse")
local theme = theme or default_theme
local header = self:newFrame(x, y, w, 35, sx, sy, sw)
header:setRoundness(10, 10, nil, "top")
local window = header:newFrame(0, 35, 0, h, sx, sy, 1, sh)
window.clipDescendants = true
local left = window:newFrame(0, -4, 4, 0, 0, 0, 0, 1):tag("left")
local right = window:newFrame(-4, -4, 4, 0, 1, 0, 0, 1):tag("right")
local bottom = window:newFrame(4, -4, -8, 4, 0, 1, 1):tag("bottom")
local bottomleft = window:newFrame(0, -4, 4, 4, 0, 1):tag("bleft")
local bottomright = window:newFrame(-4, -4, 4, 4, 1, 1):tag("bright")
gui.apply({
visibility = 0,
I_enableDragging = {gui.MOUSE_PRIMARY},
respectHierarchy = {false},
OnUpdate = function(self) self:topStack() end,
OnDragging = function(self, dx, dy)
local ox, oy, ow, oh = header:getAbsolutes()
local tag = self:getTag()
if tag == "left" or tag == "bleft" then
window:size(0, dy)
header:move(dx, 0)
header:size(-dx, 0)
else
window:size(0, dy)
header:size(dx, 0)
end
local x, y, w, h = header:getAbsolutes()
if w < 200 and (tag == "left" or tag == "bleft") then
header:setDualDim(ox, nil, 200)
elseif w < 200 then
header:setDualDim(nil, nil, 200)
end
local x, y, w, h = window:getAbsolutes()
if h < 100 then window:setDualDim(nil, nil, nil, 100) end
end,
OnDragEnd = function(self) love.mouse.setCursor(pointer) end,
OnEnter = function(self)
local tag = self:getTag()
if tag == "left" or tag == "right" then
love.mouse.setCursor(sizewe)
elseif tag == "bleft" then
love.mouse.setCursor(sizenesw)
elseif tag == "bright" then
love.mouse.setCursor(sizenwse)
else
love.mouse.setCursor(sizens)
end
end,
OnExit = function(self) love.mouse.setCursor(pointer) end,
}, left, right, bottom, bottomleft, bottomright)
local title = header:newTextLabel(text or "", 5, 0, w - 35, 35)
title.clipDescendants = true
title.visibility = 0
title.ignore = true
title:setFont(theme.fontPrimary)
title:fitFont()
function window:setTitle(t) title.text = t end
local X = header:newTextButton("", -25, -25, 20, 20, 1, 1)
X:setRoundness(10, 10)
X:respectHierarchy(false)
X.align = gui.ALIGN_CENTER
X.color = color.red
window.XButton = X
local darkenX = color.darken(color.red, .2)
X.OnEnter(function(self) self.color = darkenX end)
X.OnExit(function(self) self.color = color.red end)
if draggable then
header:enableDragging(gui.MOUSE_PRIMARY)
header:OnDragging(function(self, dx, dy) self:move(dx, dy) end)
header:OnDragEnd(function(self)
local x, y, w, h = self:getAbsolutes()
local width, height = love.graphics.getDimensions()
if x <= 0 then self:setDualDim(0) end
if y <= 0 then self:setDualDim(nil, 0) end
if x + w >= width then self:setDualDim(width - w) end
if y + h >= height then self:setDualDim(nil, height - 35) end
end)
end
window.OnClose = multi:newConnection()
X.OnPressed(function(self, ...)
window.OnClose:Fire(window, ...)
end)
window.OnClose(function()
header:setParent(gui.virtual)
love.mouse.setCursor(pointer)
end)
function window:close() window.OnClose:Fire(self) end
function window:open() header:setParent(parent) end
function window:setTheme(th)
theme = th
title.textColor = theme.colorPrimaryText
header.color = theme.colorPrimaryDark
window.color = theme.colorPrimary
end
function window:getTheme() return theme end
process:newThread(function() window:setTheme(theme) end)
window.OnSizeChanged(function() window:refresh() end)
function window:refresh() window:setTheme(theme) end
window.process = process
window.OnCreated(function(element)
if element:hasType(gui.TYPE_BUTTON) then
element:setFont(theme.fontButton)
element.color = theme.colorButtonNormal
element.textColor = theme.colorButtonText
if not element.__registeredTheme then
element.OnEnter(function(self) self.color = theme.colorButtonHighlight end)
element.OnExit(function(self) self.color = theme.colorButtonNormal end)
end
element:fitFont()
element.__registeredTheme = true
elseif element:hasType(gui.TYPE_TEXT) then
element.color = theme.colorPrimary
element:setFont(theme.fontPrimary)
element.textColor = theme.colorPrimaryText
element:fitFont()
elseif element:hasType(gui.TYPE_FRAME) then
if element.__isHeader then
element.color = theme.colorPrimaryDark
else
element.color = theme.colorPrimary
end
end
end)
return window
end
-- ── row pool ──────────────────────────────────────────────────────────────────
local COLOR_PROC_ROW = TM_THEME.colorPrimaryDark
local COLOR_ROW_EVEN = TM_THEME.colorPrimary
local COLOR_ROW_ODD = TM_THEME.colorPrimaryDark
local COLOR_DEAD = { 0.5, 0.1, 0.1 }
local function makeRowPool(scrollFrame)
local pool = { rows = {}, active = 0 }
local function makeRow(idx)
local yOff = (idx - 1) * ROW_H
local bg = scrollFrame:newFrame(0, yOff, TOTAL_W, ROW_H)
bg.drawBorder = false
-- Name label (col 1) with indent support
local nameLabel = bg:newTextLabel("", COL_X[1] + 4, 0, COL_WIDTHS[1] - 4, ROW_H)
nameLabel.align = gui.ALIGN_LEFT
nameLabel.ignore = true
-- Type, State, Status, Uptime, Priority labels
local kindLbl = bg:newTextLabel("", COL_X[2], 0, COL_WIDTHS[2], ROW_H)
local stateLbl = bg:newTextLabel("", COL_X[3], 0, COL_WIDTHS[3], ROW_H)
local statusLbl = bg:newTextLabel("", COL_X[4], 0, COL_WIDTHS[4], ROW_H)
local uptimeLbl = bg:newTextLabel("", COL_X[5], 0, COL_WIDTHS[5], ROW_H)
local priorityLbl = bg:newTextLabel("", COL_X[6], 0, COL_WIDTHS[6], ROW_H)
for _, lbl in ipairs({kindLbl, stateLbl, statusLbl, uptimeLbl, priorityLbl}) do
lbl.align = gui.ALIGN_CENTER
lbl.ignore = true
end
-- Pause / Resume button
local pauseBtn = bg:newTextButton("", COL_X[7] + 2, 2, COL_WIDTHS[7] - 4, ROW_H - 4)
pauseBtn.align = gui.ALIGN_CENTER
-- Kill button (red)
local killBtn = bg:newTextButton("Kill", COL_X[8] + 2, 2, COL_WIDTHS[8] - 4, ROW_H - 4)
killBtn.align = gui.ALIGN_CENTER
killBtn.color = color.darken(color.red, .1)
local row = {
bg = bg,
nameLabel = nameLabel,
kindLbl = kindLbl,
stateLbl = stateLbl,
statusLbl = statusLbl,
uptimeLbl = uptimeLbl,
priorityLbl = priorityLbl,
pauseBtn = pauseBtn,
killBtn = killBtn,
obj = nil,
isProc = false,
}
pauseBtn.OnReleased(function()
if not row.obj then return end
if row.obj:isPaused() then
row.obj:Resume()
else
row.obj:Pause()
end
statusLbl.text = row.obj:isPaused() and "Paused" or "Running"
pauseBtn.text = row.obj:isPaused() and "Resume" or "Pause"
end)
killBtn.OnReleased(function()
if not row.obj or row.isProc then return end
if row.obj.Kill then
row.obj:Kill()
elseif row.obj.Destroy then
row.obj:Destroy()
end
bg.color = COLOR_DEAD
end)
-- Priority label is clickable to cycle priority
priorityLbl.ignore = false
priorityLbl.OnReleased(function()
if not row.obj or row.isProc then return end
local cur = rawget(row.obj, "Priority") or 256
local next = nextPriority(cur)
if row.obj.setPriority then
row.obj:setPriority(next)
priorityLbl.text = PRIORITY_NAMES[next] or tostring(next)
end
end)
return row
end
function pool:ensure(n)
while #self.rows < n do
self.rows[#self.rows + 1] = makeRow(#self.rows + 1)
end
end
function pool:apply(data)
self:ensure(#data)
self.active = #data
for i, d in ipairs(data) do
local row = self.rows[i]
row.isProc = d.isProc
row.obj = d.isProc and nil or d.obj
-- Row y position
row.bg:setDualDim(nil, (i - 1) * ROW_H)
row.bg.visible = true
if d.isProc then
-- Processor header row
row.bg.color = COLOR_PROC_ROW
row.nameLabel.text = string.rep(" ", d.depth) .. "[" .. d.name .. "]"
row.kindLbl.text = "processor"
row.stateLbl.text = ""
row.statusLbl.text = ""
row.uptimeLbl.text = d.conns .. "c/" .. d.subs .. "s"
row.priorityLbl.text = ""
row.pauseBtn.text = ""
row.pauseBtn.visible = false
row.killBtn.visible = false
else
row.bg.color = (i % 2 == 0) and COLOR_ROW_EVEN or COLOR_ROW_ODD
row.nameLabel.text = string.rep(" ", d.depth) .. d.name
row.kindLbl.text = d.kind or ""
row.stateLbl.text = d.state or ""
row.statusLbl.text = d.active and "Running" or "Paused"
row.uptimeLbl.text = fmtUptime(d.uptime)
row.priorityLbl.text = d.fmtPri or ""
row.pauseBtn.text = d.active and "Pause" or "Resume"
row.pauseBtn.visible = true
row.killBtn.visible = true
end
end
-- Hide unused rows
for i = #data + 1, #self.rows do
self.rows[i].bg.visible = false
self.rows[i].obj = nil
end
scrollFrame:setDualDim(nil, nil, nil, math.max(#data * ROW_H, 1))
end
return pool
end
-- ── error log pool ────────────────────────────────────────────────────────────
local ERROR_ROW_H = 22
local MAX_ERRORS = 200
local function makeErrorPool(scrollFrame)
local pool = { rows = {}, entries = {} }
local function makeRow(idx)
local yOff = (idx - 1) * ERROR_ROW_H
local bg = scrollFrame:newFrame(0, yOff, TOTAL_W, ERROR_ROW_H)
bg.color = (idx % 2 == 0) and COLOR_ROW_EVEN or COLOR_ROW_ODD
bg.drawBorder = false
local lbl = bg:newTextLabel("", 4, 0, TOTAL_W - 4, ERROR_ROW_H)
lbl.align = gui.ALIGN_LEFT
lbl.ignore = true
return { bg = bg, lbl = lbl }
end
function pool:ensure(n)
while #self.rows < n do
self.rows[#self.rows + 1] = makeRow(#self.rows + 1)
end
end
function pool:addEntry(msg, source)
if #self.entries >= MAX_ERRORS then
table.remove(self.entries, 1)
end
local ts = string.format("[%.1fs]", os.clock())
self.entries[#self.entries + 1] = ts .. " [" .. (source or "?") .. "] " .. tostring(msg)
self:refresh()
end
function pool:refresh()
local n = #self.entries
self:ensure(n)
for i, entry in ipairs(self.entries) do
local row = self.rows[i]
row.bg:setDualDim(nil, (i - 1) * ERROR_ROW_H)
row.bg.visible = true
row.lbl.text = entry
end
for i = n + 1, #self.rows do
self.rows[i].bg.visible = false
end
scrollFrame:setDualDim(nil, nil, nil, math.max(n * ERROR_ROW_H, 1))
scrollFrame:scrollToBottom()
end
function pool:clear()
self.entries = {}
self:refresh()
end
return pool
end
-- ── column header row ─────────────────────────────────────────────────────────
local function makeHeader(parent, onSort)
local hdr = parent:newFrame(0, 0, TOTAL_W, ROW_H)
hdr.color = TM_THEME.colorPrimaryDark
hdr.drawBorder = false
local sortCol = nil
local sortAsc = true
local indicators = {}
for i, t in ipairs(COL_LABELS) do
local isSortable = false
for _, k in ipairs(SORT_COLS) do
if k == COL_KEYS[i] then isSortable = true; break end
end
if isSortable then
local btn = hdr:newTextButton(t, COL_X[i], 0, COL_WIDTHS[i], ROW_H)
btn.align = (i == 1) and gui.ALIGN_LEFT or gui.ALIGN_CENTER
indicators[COL_KEYS[i]] = btn
local key = COL_KEYS[i]
btn.OnReleased(function()
if sortCol == key then
sortAsc = not sortAsc
else
sortCol = key
sortAsc = true
end
-- Reset all sortable headers, then mark the active one
for j, label in ipairs(COL_LABELS) do
local b = indicators[COL_KEYS[j]]
if b then
if COL_KEYS[j] == sortCol then
b.text = label .. (sortAsc and "" or "")
else
b.text = label
end
end
end
if onSort then onSort(key, sortAsc) end
end)
else
local lbl = hdr:newTextLabel(t, COL_X[i], 0, COL_WIDTHS[i], ROW_H)
lbl.align = gui.ALIGN_CENTER
lbl.ignore = true
lbl.textColor = TM_THEME.colorPrimaryText
end
end
return hdr
end
-- ── tab bar ───────────────────────────────────────────────────────────────────
local TAB_H = 28
local function makeTabBar(parent, tabs, onSwitch)
local bar = parent:newFrame(0, 0, 0, TAB_H, 0, 0, 1)
bar.color = TM_THEME.colorPrimaryDark
bar.drawBorder = false
local tabW = math.floor(TOTAL_W / #tabs)
local btns = {}
for i, label in ipairs(tabs) do
local btn = bar:newTextButton(label, (i-1)*tabW, 0, tabW, TAB_H)
btn.align = gui.ALIGN_CENTER
btns[i] = btn
btn.OnReleased(function()
onSwitch(i)
end)
end
return bar, btns
end
-- ── sort helper ───────────────────────────────────────────────────────────────
local function sortRows(rows, key, asc)
local function cmp(a, b)
-- Processor rows always float to top within their group; we keep them stable
if a.isProc and b.isProc then return a.fullname < b.fullname end
if a.isProc then return true end
if b.isProc then return false end
local va, vb
if key == "name" then va, vb = a.name or "", b.name or ""
elseif key == "kind" then va, vb = a.kind or "", b.kind or ""
elseif key == "state" then va, vb = a.state or "", b.state or ""
elseif key == "status" then va, vb = (a.active and 0 or 1), (b.active and 0 or 1)
elseif key == "uptime" then va, vb = a.uptime or 0, b.uptime or 0
elseif key == "priority" then va, vb = a.priority or 256, b.priority or 256
else va, vb = tostring(a[key] or ""), tostring(b[key] or "")
end
if asc then return va < vb else return va > vb end
end
-- Stable-ish sort: keep proc header immediately before its children
-- For simplicity we sort the flat list but keep proc rows pinned before
-- the first non-proc row that shares the same fullname.
table.sort(rows, cmp)
end
-- ── public API ────────────────────────────────────────────────────────────────
local taskManager
function gui:showTaskManager()
if taskManager then return end
local WIN_W = TOTAL_W + 20
local WIN_H = 620
taskManager = gui:newWindow(0, 0, WIN_W, WIN_H, nil, nil, nil, nil, "Task Manager", true, TM_THEME)
taskManager.clipDescendants = true
-- ── tab bar ──────────────────────────────────────────────────────────────
local currentTab = 1 -- 1 = tasks, 2 = errors
local taskPanel, errorPanel
local tabBar, tabBtns = makeTabBar(taskManager, {"Tasks", "Errors"}, function(idx)
currentTab = idx
taskPanel.visible = (idx == 1)
errorPanel.visible = (idx == 2)
end)
-- ── tasks panel ──────────────────────────────────────────────────────────
taskPanel = taskManager:newFrame(0, TAB_H, 0, -TAB_H, 0, 0, 1, 1)
taskPanel.drawBorder = false
taskPanel.clipDescendants = true
-- Load bar strip (sits below tab bar, above column headers)
local LOAD_H = 20
local loadStrip = taskPanel:newFrame(0, 0, 0, LOAD_H, 0, 0, 1)
loadStrip.color = TM_THEME.colorPrimaryDark
loadStrip.drawBorder = false
local loadFill = loadStrip:newFrame(0, 2, 1, LOAD_H - 4) -- absolute w=1, no relative anchors
loadFill.color = { 0.1, 0.6, 0.3 }
loadFill.drawBorder = false
-- Label is created AFTER fill so it draws on top of it
local loadLbl = loadStrip:newTextLabel("Load: …", 4, 0, TOTAL_W - 8, LOAD_H)
loadLbl.align = gui.ALIGN_LEFT
loadLbl.ignore = true
local sortKey = nil
local sortAsc = true
local colHdr = makeHeader(taskPanel, function(key, asc)
sortKey = key
sortAsc = asc
end)
colHdr:setDualDim(nil, LOAD_H)
local scrollFrame = taskPanel:newScrollFrame(0, LOAD_H + ROW_H, 0, -(LOAD_H + ROW_H), 0, 0, 1, 1)
local pool = makeRowPool(scrollFrame)
-- ── error panel ──────────────────────────────────────────────────────────
errorPanel = taskManager:newFrame(0, TAB_H, 0, -TAB_H, 0, 0, 1, 1)
errorPanel.drawBorder = false
errorPanel.clipDescendants = true
errorPanel.visible = false
local errHdr = errorPanel:newFrame(0, 0, 0, ROW_H, 0, 0, 1)
errHdr.color = TM_THEME.colorPrimaryDark
errHdr.drawBorder = false
local errTitle = errHdr:newTextLabel("Error Log", 4, 0, 200, ROW_H)
errTitle.align = gui.ALIGN_LEFT
errTitle.ignore = true
local clearBtn = errHdr:newTextButton("Clear", -70, 2, 66, ROW_H - 4, 1)
clearBtn.align = gui.ALIGN_CENTER
local errScroll = errorPanel:newScrollFrame(0, ROW_H, 0, -ROW_H, 0, 0, 1, 1)
local errorPool = makeErrorPool(errScroll)
clearBtn.OnReleased(function() errorPool:clear() end)
-- ── wire up error capture ─────────────────────────────────────────────────
-- Thread errors fire on the *thread's own* OnError, not the processor's.
-- We use OnObjectCreated to hook every thread as it is born, on every
-- processor (including ones created after the task manager opens).
-- We also walk existing threads retroactively for processors already running.
local hookedThreads = {} -- weak set so we don't prevent GC
local function hookThread(th, procName)
if not th.OnError then return end
if hookedThreads[th] then return end
hookedThreads[th] = true
th.OnError(function(self, err,t)
local msg = type(err) == "string" and err or tostring(err or "unknown error")
local name = (th.getName and th:getName()) or "?"
errorPool:addEntry(msg, procName .. "/" .. name)
end)
end
local function hookProc(proc, procName)
-- Hook threads already alive on this processor
local threads = proc.threads or {}
for _, th in ipairs(threads) do
hookThread(th, procName)
end
-- Hook threads created in future on this processor
proc.OnObjectCreated(function(obj)
if obj.Type == multi.registerType("thread", "threads") then
hookThread(obj, procName)
end
end)
end
-- Root process
hookProc(multi, "root")
-- All processors currently registered
for _, proc in ipairs(multi:getProcessors()) do
hookProc(proc, proc:getName())
end
-- Any processors created after this point
multi.OnObjectCreated(function(obj)
if obj.Type == multi.registerType("process", "processes") then
hookProc(obj, obj:getName())
end
end)
-- ── stat line ─────────────────────────────────────────────────────────────
local function setStatLine(n)
taskManager:setTitle("Task Manager — " .. n .. " objects")
end
-- ── background thread: collect tasks ──────────────────────────────────────
local function isOpen()
return not taskManager:isDescendantOf(gui.virtual)
end
local pendingData = nil
local dirty = false
taskManager.process:newThread("TM_collect", function()
while true do
thread.hold(isOpen)
local data = collectTasks()
pendingData = data
dirty = true
thread.sleep(1)
end
end)
-- ── load probe ────────────────────────────────────────────────────────────
-- Install once. getLoad() is now non-blocking — just reads the EMA state.
local schedulerProbe = require("gui.core.probe")
schedulerProbe:install(multi)
-- ── main-thread update ────────────────────────────────────────────────────
taskManager.OnUpdate(function()
taskManager:topStack()
-- Apply task data
if dirty and pendingData then
dirty = false
local data = pendingData
pendingData = nil
if sortKey then
sortRows(data, sortKey, sortAsc)
end
pool:apply(data)
setStatLine(#data)
end
-- Load bar — getLoad() is now non-blocking, safe to call every frame
local pct, lagMs = multi:getLoad()
local _, _, barW, _ = loadStrip:getAbsolutes()
local fillW = math.max(1, math.floor(barW * pct / 100))
loadFill:setDualDim(nil, nil, fillW)
if pct < 50 then
loadFill.color = { 0.1, 0.6, 0.3 }
elseif pct < 80 then
loadFill.color = { 0.8, 0.6, 0.1 }
else
loadFill.color = { 0.8, 0.15, 0.1 }
end
loadLbl.text = string.format("Load: %d%% Lag: %.1fms", pct, lagMs)
end)
end
-- ── hotkey ────────────────────────────────────────────────────────────────────
ToggleTaskManager = gui:setHotKey({"lctrl","t"}) +
gui:setHotKey({"rctrl","t"})
ToggleTaskManager(function()
if not taskManager then
gui:showTaskManager()
elseif taskManager:isActive() then
taskManager:close()
else
taskManager:open()
end
end)
ToggleTaskManager:Fire()
taskManager:close()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

View File

@ -1 +0,0 @@
#

View File

@ -1,26 +0,0 @@
local gui = require("gui")
function newCanvas(domain)
local c
if domain == "visual" then
c = gui:newVisualFrame()
else
c = gui:newVirtualFrame()
end
c:fullFrame()
function c:swap(c1, c2)
local temp = c1.children
c1.children = c2.children
c2.children = temp
for i,v in pairs(c1.children) do
v.parent = c1
end
for i,v in pairs(c2.children) do
v.parent = c2
end
end
return c
end
return newCanvas

File diff suppressed because it is too large Load Diff

View File

@ -1,437 +0,0 @@
-- GIF Loader for Love2D with LZW Decompression
-- Note: love.data.compress/decompress don't support LZW, so we implement it
local GifLoader = {}
-- Pure Lua LZW decompression for GIF
local function decompressLZW(data, minCodeSize)
local clearCode = 2 ^ minCodeSize
local endCode = clearCode + 1
local nextCode = endCode + 1
local codeSize = minCodeSize + 1
local dict = {}
for i = 0, clearCode - 1 do
dict[i] = {string.byte(string.char(i))}
end
local output = {}
local bits = 0
local bitBuffer = 0
local pos = 1
local prevCode = nil
local function readCode()
while bits < codeSize do
if pos > #data then return nil end
bitBuffer = bitBuffer + bit.lshift(string.byte(data, pos), bits)
bits = bits + 8
pos = pos + 1
end
local code = bit.band(bitBuffer, bit.lshift(1, codeSize) - 1)
bitBuffer = bit.rshift(bitBuffer, codeSize)
bits = bits - codeSize
return code
end
local first = true
while true do
local code = readCode()
if not code or code == endCode then break end
if code == clearCode then
dict = {}
for i = 0, clearCode - 1 do
dict[i] = {string.byte(string.char(i))}
end
nextCode = endCode + 1
codeSize = minCodeSize + 1
prevCode = nil
first = true
else
local entry
if dict[code] then
entry = dict[code]
elseif code == nextCode and prevCode then
-- Special case: code not in dict yet
entry = {}
for i = 1, #dict[prevCode] do
entry[i] = dict[prevCode][i]
end
entry[#entry + 1] = dict[prevCode][1]
else
-- Invalid code, stop
break
end
-- Output the entry
for i = 1, #entry do
table.insert(output, entry[i])
end
-- Add new entry to dictionary
if not first and prevCode and nextCode < 4096 then
local newEntry = {}
for i = 1, #dict[prevCode] do
newEntry[i] = dict[prevCode][i]
end
newEntry[#newEntry + 1] = entry[1]
dict[nextCode] = newEntry
nextCode = nextCode + 1
-- Increase code size when needed
if nextCode >= bit.lshift(1, codeSize) and codeSize < 12 then
codeSize = codeSize + 1
end
end
prevCode = code
first = false
end
end
-- Convert output bytes to string
local result = {}
for i = 1, #output do
result[i] = string.char(output[i])
end
return table.concat(result)
end
function GifLoader.load(filepath)
local fileData = love.filesystem.read(filepath)
if not fileData then
error("Could not read GIF file: " .. filepath)
end
local gif = {
frames = {},
frameData = {}, -- Store ImageData for frame composition
delays = {},
currentFrame = 1,
timer = 0,
width = 0,
height = 0,
playing = true,
loop = true,
getWidth = function(self)
return self.width
end,
getHeight = function(self)
return self.height
end,
}
-- Parse GIF header
local header = fileData:sub(1, 6)
if header ~= "GIF87a" and header ~= "GIF89a" then
error("Not a valid GIF file")
end
-- Read logical screen descriptor
local pos = 7
gif.width = string.byte(fileData, pos) + string.byte(fileData, pos + 1) * 256
gif.height = string.byte(fileData, pos + 2) + string.byte(fileData, pos + 3) * 256
local packed = string.byte(fileData, pos + 4)
local hasGlobalColorTable = bit.band(packed, 0x80) ~= 0
local backgroundColorIndex = string.byte(fileData, pos + 5)
pos = pos + 7
-- Read global color table
local globalColorTable = {}
if hasGlobalColorTable then
local size = 2 ^ (bit.band(packed, 0x07) + 1)
for i = 1, size do
local r = string.byte(fileData, pos) / 255
local g = string.byte(fileData, pos + 1) / 255
local b = string.byte(fileData, pos + 2) / 255
table.insert(globalColorTable, {r, g, b, 1})
pos = pos + 3
end
end
-- Parse blocks
local delay = 0.1
local transparentIndex = nil
local disposalMethod = 0
local delayForNextFrame = 0.1 -- Track delay for next frame
while pos <= #fileData do
local separator = string.byte(fileData, pos)
if separator == 0x21 then -- Extension
local label = string.byte(fileData, pos + 1)
pos = pos + 2
if label == 0xF9 then -- Graphic Control Extension
local blockSize = string.byte(fileData, pos)
pos = pos + 1
local flags = string.byte(fileData, pos)
disposalMethod = bit.rshift(bit.band(flags, 0x1C), 2)
local hasTransparency = bit.band(flags, 0x01) ~= 0
local delayTime = string.byte(fileData, pos + 1) + string.byte(fileData, pos + 2) * 256
-- GIF delay is in hundredths of a second, convert to seconds
-- Many GIFs use 0 or very small delays, set a minimum
if delayTime == 0 then
delayForNextFrame = 0.1 -- Default 100ms
elseif delayTime <= 2 then
delayForNextFrame = 0.02 -- Minimum 20ms for very fast animations
else
delayForNextFrame = delayTime / 100
end
if hasTransparency then
transparentIndex = string.byte(fileData, pos + 3)
else
transparentIndex = nil
end
pos = pos + blockSize + 1
else
-- Skip other extensions
repeat
local blockSize = string.byte(fileData, pos)
pos = pos + 1
if blockSize > 0 then
pos = pos + blockSize
end
until blockSize == 0
end
elseif separator == 0x2C then -- Image Descriptor
pos = pos + 1
local left = string.byte(fileData, pos) + string.byte(fileData, pos + 1) * 256
local top = string.byte(fileData, pos + 2) + string.byte(fileData, pos + 3) * 256
local width = string.byte(fileData, pos + 4) + string.byte(fileData, pos + 5) * 256
local height = string.byte(fileData, pos + 6) + string.byte(fileData, pos + 7) * 256
local imgPacked = string.byte(fileData, pos + 8)
local hasLocalColorTable = bit.band(imgPacked, 0x80) ~= 0
local interlaced = bit.band(imgPacked, 0x40) ~= 0
pos = pos + 9
local colorTable = globalColorTable
if hasLocalColorTable then
local size = 2 ^ (bit.band(imgPacked, 0x07) + 1)
colorTable = {}
for i = 1, size do
local r = string.byte(fileData, pos) / 255
local g = string.byte(fileData, pos + 1) / 255
local b = string.byte(fileData, pos + 2) / 255
table.insert(colorTable, {r, g, b, 1})
pos = pos + 3
end
end
-- Read LZW minimum code size
local minCodeSize = string.byte(fileData, pos)
pos = pos + 1
-- Read compressed image data blocks
local compressedData = {}
while true do
local blockSize = string.byte(fileData, pos)
pos = pos + 1
if blockSize == 0 then break end
table.insert(compressedData, fileData:sub(pos, pos + blockSize - 1))
pos = pos + blockSize
end
-- Decompress image data
local indexStream = decompressLZW(table.concat(compressedData), minCodeSize)
-- Create image data
local imageData = love.image.newImageData(gif.width, gif.height)
-- Fill with background if first frame
if #gif.frames == 0 and backgroundColorIndex and globalColorTable[backgroundColorIndex + 1] then
local bg = globalColorTable[backgroundColorIndex + 1]
for y = 0, gif.height - 1 do
for x = 0, gif.width - 1 do
imageData:setPixel(x, y, bg[1], bg[2], bg[3], bg[4])
end
end
elseif #gif.frames > 0 then
-- Copy previous frame if needed
local prevData = gif.frameData[#gif.frameData]
imageData:paste(prevData, 0, 0, 0, 0, gif.width, gif.height)
end
-- Draw current frame
if indexStream and #indexStream > 0 then
local idx = 1
-- Use mapPixel for faster pixel operations
local function setPixels(x, y, r, g, b, a)
if x >= left and x < left + width and y >= top and y < top + height then
local pixelIdx = (y - top) * width + (x - left) + 1
if pixelIdx <= #indexStream then
local colorIndex = string.byte(indexStream, pixelIdx)
-- Skip transparent pixels
if transparentIndex == nil or colorIndex ~= transparentIndex then
if colorTable[colorIndex + 1] then
local color = colorTable[colorIndex + 1]
return color[1], color[2], color[3], color[4]
end
end
end
end
return r, g, b, a
end
-- Only update the region where the frame is located
for y = top, top + height - 1 do
for x = left, left + width - 1 do
local pixelIdx = (y - top) * width + (x - left) + 1
if pixelIdx <= #indexStream then
local colorIndex = string.byte(indexStream, pixelIdx)
-- Skip transparent pixels
if transparentIndex == nil or colorIndex ~= transparentIndex then
if colorTable[colorIndex + 1] then
local color = colorTable[colorIndex + 1]
imageData:setPixel(x, y, color[1], color[2], color[3], color[4])
end
end
end
end
end
end
table.insert(gif.frameData, imageData)
table.insert(gif.frames, love.graphics.newImage(imageData))
table.insert(gif.delays, delayForNextFrame)
-- Reset delay for next frame
delayForNextFrame = 0.1
elseif separator == 0x3B then -- Trailer
break
else
pos = pos + 1
end
end
if #gif.frames == 0 then
error("No frames found in GIF")
end
-- Ensure all frames have valid delays
for i = 1, #gif.delays do
if gif.delays[i] <= 0 or gif.delays[i] ~= gif.delays[i] then -- check for 0 or NaN
gif.delays[i] = 0.1
end
end
return gif
end
function GifLoader.Updater(gif, proc)
local wait = function()
return gif.playing or gif.kill
end
proc:newThread("Gif Handler",function()
while true do
-- Only run if not paused
if gif.kill then -- When we want to clean up
thread.kill()
end
thread.hold(wait)
thread.sleep(gif.delays[gif.currentFrame] * 4)
gif.currentFrame = gif.currentFrame + 1
if gif.currentFrame > #gif.frames then
if gif.loop then
gif.currentFrame = 1
else
gif.currentFrame = #gif.frames
gif.playing = false
gif.timer = 0
end
end
end
end)
end
function GifLoader.update(gif, dt)
if not gif.playing or #gif.frames <= 1 then return end
gif.timer = gif.timer + dt
-- Simple, accurate frame advancement
if gif.timer >= gif.delays[gif.currentFrame] then
-- Subtract the current frame's delay
gif.timer = gif.timer - gif.delays[gif.currentFrame]
-- Move to next frame
gif.currentFrame = gif.currentFrame + 1
if gif.currentFrame > #gif.frames then
if gif.loop then
gif.currentFrame = 1
else
gif.currentFrame = #gif.frames
gif.playing = false
gif.timer = 0
end
end
-- If we've accumulated too much time (lag spike), cap it
if gif.timer > 0.5 then
gif.timer = 0
end
end
end
function GifLoader.draw(gif, x, y, r, sx, sy, ox, oy)
if gif.frames[gif.currentFrame] then
love.graphics.draw(gif.frames[gif.currentFrame], x, y, r or 0, sx or 1, sy or 1, ox or 0, oy or 0)
end
end
function GifLoader.play(gif)
gif.playing = true
end
function GifLoader.pause(gif)
gif.playing = false
end
function GifLoader.reset(gif)
gif.currentFrame = 1
gif.timer = 0
end
function GifLoader.setFixedFramerate(gif, fps)
local delay = 1 / fps
for i = 1, #gif.delays do
gif.delays[i] = delay
end
end
function GifLoader.getInfo(gif)
return {
width = gif.width,
height = gif.height,
frameCount = #gif.frames,
delays = gif.delays, -- Show actual delays for debugging
totalDuration = (function()
local total = 0
for i = 1, #gif.delays do
total = total + gif.delays[i]
end
return total
end)()
}
end
return GifLoader

View File

@ -1,115 +0,0 @@
--[[
scheduler_probe.lua
-------------------
A drop-in replacement for multi:getLoad() based on scheduler tick-slip
rather than step-count benchmarking.
THEORY
------
Schedule a repeating timer at a fixed interval T. Each time it fires,
measure how much later than T it actually arrived. On an idle scheduler
the slip is near zero. Under load the main loop is busy with other tasks
between iterations, so ticks are delayed.
We express load as:
lag = actual_interval - target_interval (seconds)
lag_ratio = lag / target_interval (0 = perfect, 1 = 1 full interval late)
load% = clamp(lag_ratio * 100, 0, 100)
To smooth out single-frame spikes we keep an exponential moving average
(EMA) of lag_ratio with a configurable smoothing factor.
WHY THIS IS BETTER THAN THE STEP-COUNT APPROACH
------------------------------------------------
- No calibration baseline that drifts with object count or warm-up
- No magic exponents or divisors
- Does not block or create temporary objects on each call
- Measures actual scheduler responsiveness, not raw throughput
- Works correctly regardless of which processor calls it
- A single lightweight TLoop is the only permanent overhead
USAGE
-----
local probe = require("scheduler_probe")
probe:install(multi) -- once, at startup
-- anywhere, non-blocking:
local load, lagMs = multi:getLoad()
-- load : integer 0-100
-- lagMs : smoothed lag in milliseconds (useful for display)
OPTIONAL PARAMETERS
-------------------
probe:install(multi, {
interval = 0.05, -- probe fires every N seconds (default 0.05 = 50ms)
alpha = 0.15, -- EMA smoothing factor 0-1 (default 0.15)
-- lower = smoother but slower to react
-- higher = more reactive but noisier
maxLag = 0.5, -- lag value (seconds) that maps to 100% load (default 0.5)
-- tune this to match your target frame budget
})
]]
local probe = {}
-- EMA state — written by the TLoop callback, read by getLoad()
-- Both are plain numbers so Lua's assignment is atomic within one thread.
local _emaRatio = 0 -- smoothed lag / maxLag, clamped 0-1
local _lagMs = 0 -- smoothed lag in milliseconds for display
local _installed = false
function probe:install(multi_obj, opts)
if _installed then return end
_installed = true
opts = opts or {}
local INTERVAL = opts.interval or 0.05 -- seconds between probes
local ALPHA = opts.alpha or 0.15 -- EMA weight for new sample
local MAX_LAG = opts.maxLag or 0.5 -- seconds of lag = 100% load
local clock = os.clock
-- Track when the tick *should* have fired so we can compute slip
-- relative to the scheduled time, not relative to the previous firing.
-- This avoids error accumulation over long runs.
local expectedTime = clock() + INTERVAL
local tloop = multi_obj:newTLoop(nil, INTERVAL)
tloop:setName("SchedulerProbe")
tloop:setPriority("core") -- run as early as possible each frame
tloop.OnLoop(function(self, life, dt)
local now = clock()
local lag = math.max(0, now - expectedTime) -- never negative
local ratio = math.min(lag / MAX_LAG, 1) -- clamp to [0,1]
-- Exponential moving average: new = alpha*sample + (1-alpha)*old
_emaRatio = ALPHA * ratio + (1 - ALPHA) * _emaRatio
_lagMs = ALPHA * lag*1000 + (1 - ALPHA) * _lagMs
-- Advance expected time by one interval from where it *should* have been,
-- not from now — prevents the probe from drifting under sustained load.
expectedTime = expectedTime + INTERVAL
-- If we fall more than one interval behind (e.g. after a long GC pause),
-- re-anchor so we don't fire in a catch-up burst.
if now > expectedTime + INTERVAL then
expectedTime = now + INTERVAL
end
end)
-- Replace multi:getLoad() with a non-blocking version that just reads the EMA
function multi_obj:getLoad()
local pct = math.ceil(_emaRatio * 100)
return pct, _lagMs
end
-- Also expose raw probe state for diagnostics
function multi_obj:getSchedulerLag()
return _lagMs, _emaRatio
end
return tloop
end
return probe

View File

@ -1,91 +0,0 @@
local gui = require("gui")
local multi, thread = require("multi"):init()
local transition = require("gui.core.transitions")
-- Triggers press then release
local function getPosition(obj, x, y)
if not x or y then
local cx, cy, w, h = obj:getAbsolutes()
return cx + w/2, cy + h/2
else
return x, y
end
end
proc = gui:getProcessor()
local simulate = {}
function simulate:Press(button, x, y, istouch)
if self then
x, y = getPosition(self, x, y)
elseif x == nil or y == nil then
x, y = love.mouse.getPosition()
end
love.mouse.setPosition(x, y)
gui.Events.OnMousePressed:Fire(x, y, button or gui.MOUSE_PRIMARY, istouch or false)
end
function simulate:Release(button, x, y, istouch)
if self then
x, y = getPosition(self, x, y)
elseif x == nil or y == nil then
x, y = love.mouse.getPosition()
end
love.mouse.setPosition(x, y)
gui.Events.OnMouseReleased:Fire(x, y, button or gui.MOUSE_PRIMARY, istouch or false)
end
simulate.Click = proc:newFunction(function(self, button, x, y, istouch)
if self then
x, y = getPosition(self, x, y)
elseif x == nil or y == nil then
x, y = love.mouse.getPosition()
end
love.mouse.setPosition(x, y)
gui.Events.OnMousePressed:Fire(x, y, button or gui.MOUSE_PRIMARY, istouch or false)
thread.skip(1)
gui.Events.OnMouseReleased:Fire(x, y, button or gui.MOUSE_PRIMARY, istouch or false)
end, true)
simulate.Move = proc:newFunction(function(self, dx, dy, x, y, istouch)
local dx, dy = dx or 0, dy or 0
if self then
x, y = getPosition(self, x, y)
elseif x == nil or y == nil then
x, y = love.mouse.getPosition()
end
if dx == 0 and dy == 0 then
_x, _y = love.mouse.getPosition()
if x == _x and y == _y then
return
end
local dx, dy = 0, 0
dx = x - _x
dy = y - _y
return simulate.Move(nil, dx, dy)
end
gui.Events.OnMouseMoved:Fire(x, y, 0, 0, istouch or false)
thread.skip(1)
local gx = transition.glide(0, dx, .25)
local gy = transition.glide(0, dy, .25)
local xx = gx()
xx.OnStep(function(p)
_x, _y = love.mouse.getPosition()
love.mouse.setPosition(x + p, _y)
end)
local yy = gy()
yy.OnStep(function(p)
_x, _y = love.mouse.getPosition()
love.mouse.setPosition(_x, y + p)
end)
local event = xx.OnStop * yy.OnStop
if not(dx==0 and dy == 0) then
thread.hold(event)
end
gui.Events.OnMouseMoved:Fire(x + dx, y + dy, 0, 0, istouch or false)
end, true)
return simulate

View File

@ -1,135 +0,0 @@
local color = require("gui.core.color")
local theme = {}
local defaultFont = love.graphics.getFont()
theme.__index = theme
local function generate_harmonious_colors(num_colors, lightness)
local base_hue = math.random(0, 360) -- random starting hue
local colors = {}
for i = 1, num_colors do
local new_hue = (base_hue + (360 / num_colors) * i) % 360 -- offset hue by 1/n of the color wheel
if lightness == "dark" then
table.insert(colors, color.new(color.hsl(new_hue, math.random(45, 55), math.random(30, 40))))
elseif lightness == "light" then
table.insert(colors, color.new(color.hsl(new_hue, math.random(45, 55), math.random(60, 80))))
else
table.insert(colors, color.new(color.hsl(new_hue, math.random(45, 55), math.random(30, 80))))
end
end
return colors
end
function theme:random(seed, lightness, rand)
local seed = seed or math.random(0,9999999999)
math.randomseed(seed)
local harmonious_colors = generate_harmonious_colors(3, lightness)
local t = theme:new(unpack(harmonious_colors))
if lightness == "dark" then
t.colorPrimaryText = color.lighten(t.colorPrimaryText, .8)
t.colorButtonText = color.lighten(t.colorButtonText, .7)
elseif lightness == "light" then
t.colorPrimaryText = color.darken(t.colorPrimaryText, .8)
t.colorButtonText = color.darken(t.colorButtonText, .7)
else
if color.getAverageLightness(t.colorPrimary)<.5 then
t.colorPrimaryText = color.lighten(t.colorPrimaryText, .5)
t.colorButtonNormal = color.lighten(t.colorButtonNormal, .2)
else
t.colorPrimaryText = color.darken(t.colorPrimaryText, .3)
end
if color.getAverageLightness(t.colorPrimary)<.5 then
t.colorButtonText = color.lighten(t.colorButtonText, .5)
else
t.colorButtonText = color.darken(t.colorButtonText, .3)
end
end
t.seed = seed
return t
end
function theme:dump()
return '"' .. table.concat({color.rgbToHex(self.colorPrimary), color.rgbToHex(self.colorPrimaryText), color.rgbToHex(self.colorButtonText)},"\",\"") .. '"'
end
local function newColor(c,default)
if not c then
return default
end
return color.new(c)
end
function theme:new(colorPrimary, primaryText, buttonText, buttonNormal, primaryTextFont, buttonTextFont)
local c = {}
setmetatable(c, theme)
if type(colorPrimary) == "table" then
local opts = colorPrimary
c.colorPrimary = newColor(opts.primary)
c.colorPrimaryDark = newColor(opts.primaryDark, color.darken(c.colorPrimary,.4))
c.colorPrimaryText = newColor(opts.primaryText)
c.colorButtonNormal = newColor(opts.buttonNormal, color.darken(c.colorPrimary,.2))
c.colorButtonHighlight = newColor(opts.buttonHighlight, color.lighten(c.colorPrimary,.2))
c.colorButtonText = newColor(opts.buttonText, c.colorPrimaryText)
c.fontPrimary = opts.textFont or defaultFont
c.fontButton = opts.buttonTextFont or defaultFont
for i,v in pairs(colorPrimary) do
if not c[i] then
c[i] = v -- only overwrite non managed fields
end
end
return c
end
c.colorPrimary = color.new(colorPrimary)
c.colorPrimaryDark = color.darken(c.colorPrimary,.4)
c.colorPrimaryText = color.new(primaryText)
c.colorButtonNormal = newColor(buttonNormal) or color.darken(c.colorPrimary,.2)
c.colorButtonHighlight = color.lighten(c.colorButtonNormal,.2)
c.colorButtonText = color.new(buttonText)
c.fontPrimary = primaryTextFont or defaultFont
c.fontButton = buttonTextFont or defaultFont
return c
end
function theme:setColorPrimary(c)
self.colorPrimary = color.new(c)
end
function theme:setColorPrimaryDark(c)
self.colorPrimaryDark = color.new(c)
end
function theme:setColorPrimaryText(c)
self.colorPrimaryText = color.new(c)
end
function theme:setColorButtonNormal(c)
self.colorButtonNormal = color.new(c)
end
function theme:setColorButtonHighlight(c)
self.colorButtonHighlight = color.new(c)
end
function theme:setColorButtonText(c)
self.colorButtonText = color.new(c)
end
function theme:setFontPrimary(c)
self.fontPrimary = c
end
function theme:setFontButton(c)
self.fontButton = c
end
function theme:getSeed()
return self.seed
end
return theme

View File

@ -1,93 +0,0 @@
local gui = require("gui")
local multi, thread = require("multi"):init()
local processor = gui:newProcessor("Transition Processor")
local transition = {}
local width, height, flags = love.window.getMode()
local fps = 60
if flags.refreshrate > 0 then
fps = flags.refreshrate
end
transition.__index = transition
transition.__call = function(t, start, stop, time, ...)
local args = {...}
return function(st, sp, ti)
local s = st or start
local e = sp or stop
local dur = ti or time or 1
if not s or not e then return multi.error("start and stop must be supplied") end
if s == e then
local temp = {
OnStep = function() end,
OnStop = multi:newConnection()
}
processor:newTask(function()
temp.OnStop:Fire()
end)
return temp
end
local handle = t.func(t, s, e, dur, unpack(args))
return {
OnStep = handle.OnStatus,
OnStop = handle.OnReturn + handle.OnError,
Kill = function() t:Kill() end
}
end
end
function transition:newTransition(func)
local c = {}
setmetatable(c, self)
c.fps = fps
c.func = processor:newFunction(func)
c.OnStop = multi:newConnection()
c.kill = false
function c:SetFPS(f)
self.fps = f
end
function c:GetFPS()
return self.fps
end
function c:Kill()
if c.running then
c.kill = true
end
end
return c
end
transition.glide = transition:newTransition(function(t, start, stop, time)
local split = stop - start
local startTime = love.timer.getTime()
local endTime = startTime + time
local stepTime = 1 / t.fps
t.running = true
while not t.kill do
thread.sleep(stepTime)
local now = love.timer.getTime()
local elapsed = now - startTime
local clamped = math.min(elapsed, time)
local value = start + (clamped / time) * split
thread.pushStatus(value, clamped)
if now >= endTime then
break
end
end
t.running = false
t.kill = false
end)
return transition

File diff suppressed because it is too large Load Diff

View File

@ -1,186 +0,0 @@
# ── Full GUI YAML reference ─────────────────────────────────────────
# Every element shares these fields:
type: frame # frame | virtual-frame | visual-frame |
# label | button | textbox |
# image | image-button | video
# ── Position & size (dual-dimension system) ─────────────────────────
# Offset (pixels):
x: 10
y: 10
w: 200
h: 50
# Or as lists:
pos: [10, 10]
size: [200, 50]
# Scale (0.01.0 of parent):
sx: 0.0 # scale pos x
sy: 0.0 # scale pos y
sw: 0.5 # scale size x — 50% of parent width
sh: 1.0 # scale size y — 100% of parent height
# Or as lists:
scale-pos: [0.0, 0.0]
scale-size: [0.5, 1.0]
# Shorthand for "fill parent completely":
full-frame: true
# ── Appearance ───────────────────────────────────────────────────────
color: "#3a7bd5" # hex string
color: [58, 123, 213] # RGB 0-255
color: [0.23, 0.48, 0.84] # RGB 0-1
border-color: "#000000"
draw-border: true
visible: true
active: true
visibility: 1.0 # 0.01.0 alpha for the background rect
rotation: 45 # degrees
# ── Form factor ──────────────────────────────────────────────────────
form: rectangle # default — no extra fields needed
form: circle
radius: 40 # optional; derived from w if omitted
segments: 32
form: arc
radius: 60
arc-type: open # open | closed | pie
angle-start: 0
angle-end: 3.14159
segments: 32
# ── Roundness ────────────────────────────────────────────────────────
roundness: 8 # uniform rx/ry
roundness: [8, 8, 30] # rx, ry, segments
roundness: top # "top" or "bottom" special mode
roundness:
side: top # full table form
# ── Tags ─────────────────────────────────────────────────────────────
tag: "my-element" # single fast-lookup tag (gui:tag)
tags: # multi-tag (gui:setTag)
- draggable-panel
- visual
# ── Stack order ──────────────────────────────────────────────────────
stack: top # bring to front
stack: bottom # send to back
# ── Centering ────────────────────────────────────────────────────────
center-x: true # horizontally center within parent
center-y: true # vertically center within parent
# ── Dragging ─────────────────────────────────────────────────────────
draggable: 1 # mouse button (1=primary, 2=secondary, 3=middle)
# false/omit to disable
# ── Hierarchy & clipping ─────────────────────────────────────────────
respect-hierarchy: true # blocks presses when covered by sibling
clip-descendants: true # scissor-clips all children to this rect
# ── Square locking ───────────────────────────────────────────────────
square: w # force height = width
square: h # force width = height
# ── Text element fields (label, button, textbox) ─────────────────────
text: "Hello, world!"
align: left # left | center | right
text-color: "#ffffff"
text-visibility: 1.0
text-scale: [1.0, 1.0] # [scaleX, scaleY]
text-offset: [0, 0] # [offsetX, offsetY]
text-shear: [0, 0] # [shearX, shearY]
font: 16 # size, uses default font
font: "fonts/Roboto-Regular.ttf" # path, uses font-size below
font-size: 18
font:
file: "fonts/Roboto-Regular.ttf"
size: 18
fit-font: true # auto-fit font to element bounds
fit-font:
min: 8
max: 200
scale: 0.95 # shrink slightly from computed best
center-font: true # vertically center glyphs in box
center-font: 10 # with y_offset
# ── Image element fields (image, image-button) ───────────────────────
source: "assets/logo.png"
tile: [0, 0, 64, 64] # sub-quad [x, y, w, h]
scale-x: 1.0
scale-y: 1.0
flip: horizontal # horizontal | vertical | both
image-color: "#ffffff"
image-visibility: 1.0
# Gradient (replaces solid image with a generated gradient image):
gradient:
direction: vertical # vertical | horizontal
colors:
- [255, 80, 80, 255]
- [80, 80, 255, 255]
# ── Video element fields ──────────────────────────────────────────────
source: "assets/intro.ogv"
volume: 0.8
autoplay: true
video-color: "#ffffff"
video-visibility: 1.0
# ── Events ───────────────────────────────────────────────────────────
# Value can be a global function name (string) or inline Lua source.
on-pressed: "myPressHandler"
on-released: "myReleaseHandler"
on-released-outer: "myOuterRelease"
on-pressed-outer: "myOuterPress"
on-enter: "onHoverEnter"
on-exit: "onHoverExit"
on-moved: "onMouseMoved"
on-drag-start: "onDragStart"
on-dragging: "onDragging"
on-drag-end: "onDragEnd"
on-wheel: "onWheel"
on-size-changed: "onResized"
on-position-changed: "onMoved"
on-destroy: "onDestroy"
on-load: "onLoaded"
on-return: "onSubmit" # textbox only
on-update: "onUpdate" # called every frame
# Inline Lua (multi-line string):
on-pressed: |
print("pressed!", self.text)
# Per-element hotkeys:
hotkeys:
- keys: [lctrl, s]
action: "saveDocument"
- keys: [escape]
action: "closeDialog"
# ── Children ─────────────────────────────────────────────────────────
children:
- type: label
text: "I am a child"
x: 10
y: 10
w: 180
h: 30
color: "#2a2a2a"
text-color: "#ffffff"
align: center
- type: button
text: "Click me"
x: 10
y: 50
w: 100
h: 36
on-pressed: "handleClick"
children: [] # buttons can also have children

File diff suppressed because it is too large Load Diff

View File

@ -1,70 +0,0 @@
local blur_h = love.graphics.newShader([[
extern vec2 size;
extern float radius;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec2 step = vec2(1.0 / size.x, 0.0);
vec4 result = vec4(0.0);
float total = 0.0;
float sigma = radius * 0.5;
float r = floor(radius);
float i = -r;
while (i <= r) {
float weight = exp(-0.5 * (i * i) / (sigma * sigma));
result += Texel(tex, tc + step * i) * weight;
total += weight;
i = i + 1.0;
}
return (result / total) * color;
}
]])
local blur_v = love.graphics.newShader([[
extern vec2 size;
extern float radius;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec2 step = vec2(0.0, 1.0 / size.y);
vec4 result = vec4(0.0);
float total = 0.0;
float sigma = radius * 0.5;
float r = floor(radius);
float i = -r;
while (i <= r) {
float weight = exp(-0.5 * (i * i) / (sigma * sigma));
result += Texel(tex, tc + step * i) * weight;
total += weight;
i = i + 1.0;
}
return (result / total) * color;
}
]])
local c = {}
function c:setBlur(radius)
radius = radius or 8
c.__blur = {
radius = radius,
canvas1 = nil, -- populated on first draw
canvas2 = nil,
shader_h = blur_h,
shader_v = blur_v,
}
end
function c:clearBlur()
c.__blur = nil
end
function c:setBlurRadius(radius)
if c.__blur then
c.__blur.radius = radius
end
end
return {init = function(gui) gui.registerExtension(c) end}

View File

@ -1,274 +0,0 @@
local shaders = {}
function NewShader(name, shader, opt_args)
local uniforms = {}
local opt_args = opt_args or {}
for typ, uname in shader:gmatch("extern%s+(%w+)%s+(%w+)%s*;") do
if uname ~= "time" and uname ~= "size" then
table.insert(uniforms, "Argument \"" .. uname .. "\" is expected to be: \"" .. typ .. "\"")
opt_args[uname] = true
end
end
if #opt_args > 0 or #uniforms > 0 then
opt_args.source = love.graphics.newShader(shader)
shaders[name] = opt_args
if opt_args.usage == nil then
opt_args.usage = function() return table.concat(uniforms,"\n").."\n" end
end
else
shaders[name] = love.graphics.newShader(shader)
end
end
function GetShaderUniforms(name)
local source = shaders[name] -- we need the source not the compiled shader
local uniforms = {}
for type, uname in source:gmatch("extern%s+(%w+)%s+(%w+)%s*;") do
uniforms[uname] = type
end
return uniforms
end
-- ─────────────────────────────────────────────
-- GLOW (original kept for reference)
-- Uniforms: vec2 size, float time
-- ─────────────────────────────────────────────
NewShader("glow", [[
extern float time;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 pixel = Texel(tex, tc);
float pulse = 0.15 * sin(time * 3.0) + 0.85;
return vec4(pixel.rgb * pulse, pixel.a) * color;
}
]])
-- ─────────────────────────────────────────────
-- GRAYSCALE
-- Converts the sprite to grayscale (good for disabled/inactive state).
-- Uniforms: float grayScale (0.0 = full color, 1.0 = full gray)
-- ─────────────────────────────────────────────
NewShader("grayscale", [[
extern float amount;
vec4 effect(vec4 col, Image tex, vec2 tc, vec2 sc) {
vec4 pixel = Texel(tex, tc) * col;
float gray = dot(pixel.rgb, vec3(0.299, 0.587, 0.114));
float a = clamp(amount, 0.0, 1.0);
vec3 mixed = pixel.rgb * (1.0 - a) + vec3(gray) * a;
return vec4(mixed, pixel.a);
}
]])
-- ─────────────────────────────────────────────
-- CHROMATIC ABERRATION
-- Uniforms: float amount (try 0.003 0.012)
-- ─────────────────────────────────────────────
NewShader("chromatic_aberration", [[
extern float amount;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 r = Texel(tex, tc + vec2(amount, 0.0));
vec4 g = Texel(tex, tc);
vec4 b = Texel(tex, tc + vec2(-amount, 0.0));
vec4 pixel = vec4(r.r, g.g, b.b, g.a) * color;
return pixel;
}
]])
-- ─────────────────────────────────────────────
-- GAUSSIAN BLUR (single-pass, 9-tap)
-- Run twice with {1,0} then {0,1} for full 2D blur.
-- Uniforms: vec2 direction ({1,0} or {0,1}), vec2 size
-- ─────────────────────────────────────────────
NewShader("blur", [[
extern vec2 direction;
extern vec2 size;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec2 px = direction / size;
vec4 result = vec4(0.0);
result += Texel(tex, tc - px * 4.0) * 0.0162;
result += Texel(tex, tc - px * 3.0) * 0.0540;
result += Texel(tex, tc - px * 2.0) * 0.1216;
result += Texel(tex, tc - px * 1.0) * 0.1945;
result += Texel(tex, tc ) * 0.2270;
result += Texel(tex, tc + px * 1.0) * 0.1945;
result += Texel(tex, tc + px * 2.0) * 0.1216;
result += Texel(tex, tc + px * 3.0) * 0.0540;
result += Texel(tex, tc + px * 4.0) * 0.0162;
return result * color;
}
]])
-- ─────────────────────────────────────────────
-- SCANLINES
-- Uniforms: float strength (0.0-1.0), float count (e.g. 200)
-- ─────────────────────────────────────────────
NewShader("scanlines", [[
extern float strength;
extern float count;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 pixel = Texel(tex, tc) * color;
float line = sin(tc.y * count * 3.14159) * 0.5 + 0.5;
float dim = 1.0 - strength * (1.0 - line);
return vec4(pixel.rgb * dim, pixel.a);
}
]])
-- ─────────────────────────────────────────────
-- PIXELATE
-- Uniforms: float pixels (grid cell size, e.g. 8.0), vec2 size
-- ─────────────────────────────────────────────
NewShader("pixelate", [[
extern float pixels;
extern vec2 size;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec2 grid = floor(tc * size / pixels) * pixels / size;
return Texel(tex, grid) * color;
}
]])
-- ─────────────────────────────────────────────
-- VIGNETTE
-- Uniforms: float intensity (0.0-1.0), float smoothness (0.0-1.0)
-- ─────────────────────────────────────────────
NewShader("vignette", [[
extern float intensity;
extern float smoothness;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 pixel = Texel(tex, tc) * color;
vec2 uv = tc - 0.5;
float dist = length(uv);
float vig = smoothstep(0.8, 0.8 - smoothness, dist * intensity);
return vec4(pixel.rgb * vig, pixel.a);
}
]])
-- ─────────────────────────────────────────────
-- HUE SHIFT
-- Uniforms: float hue (radians, 0 = no change)
-- ─────────────────────────────────────────────
NewShader("hue_shift", [[
extern float hue;
vec3 rgb2hsv(vec3 c) {
vec4 K = vec4(0.0, -1.0/3.0, 2.0/3.0, -1.0);
vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
float d = q.x - min(q.w, q.y);
float e = 1.0e-10;
return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 pixel = Texel(tex, tc) * color;
vec3 hsv = rgb2hsv(pixel.rgb);
hsv.x = fract(hsv.x + hue / 6.28318);
return vec4(hsv2rgb(hsv), pixel.a);
}
]])
--[[
NEEDS TESTING :P
]]
-- ─────────────────────────────────────────────
-- DISSOLVE
-- Uniforms: float threshold (0.0=visible, 1.0=gone)
-- float edge_width (e.g. 0.05)
-- vec4 edge_color
-- ─────────────────────────────────────────────
NewShader("dissolve", [[
extern float threshold;
extern float edge_width;
extern vec4 edge_color;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1,0)), u.x),
mix(hash(i + vec2(0,1)), hash(i + vec2(1,1)), u.x), u.y);
}
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 pixel = Texel(tex, tc) * color;
float n = noise(tc * 8.0);
if (n < threshold) discard;
if (n < threshold + edge_width) return edge_color;
return pixel;
}
]])
-- ─────────────────────────────────────────────
-- WAVE
-- Uniforms: float time, float amplitude (e.g. 0.01), float frequency (e.g. 10.0)
-- ─────────────────────────────────────────────
NewShader("wave", [[
extern float time;
extern float amplitude;
extern float frequency;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec2 uv = tc;
uv.x += sin(uv.y * frequency + time * 3.0) * amplitude;
uv.y += sin(uv.x * frequency + time * 2.5) * amplitude * 0.6;
return Texel(tex, uv) * color;
}
]])
-- ─────────────────────────────────────────────
-- RAINBOW / IRIDESCENCE
-- Uniforms: float time, float speed (e.g. 1.0), float spread (e.g. 2.0)
-- ─────────────────────────────────────────────
NewShader("rainbow", [[
extern float time;
extern float speed;
extern float spread;
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 pixel = Texel(tex, tc) * color;
float h = fract(tc.x * spread + time * speed * 0.1);
vec3 rainbow = hsv2rgb(vec3(h, 0.8, 1.0));
return vec4(pixel.rgb * rainbow, pixel.a);
}
]])
-- ─────────────────────────────────────────────
-- FLASH / HIT-FLASH
-- Uniforms: float flash (0.0=normal, 1.0=full flash)
-- vec4 flash_color (e.g. {1,1,1,1})
-- ─────────────────────────────────────────────
NewShader("flash", [[
extern float flash;
extern vec4 flash_color;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 pixel = Texel(tex, tc) * color;
vec3 mixed = pixel.rgb * (1.0 - flash) + flash_color.rgb * flash;
return vec4(mixed, pixel.a);
}
]])
-- ─────────────────────────────────────────────
-- INVERT
-- Uniforms: float amount (0.0 = normal, 1.0 = fully inverted)
-- ─────────────────────────────────────────────
NewShader("invert", [[
extern float amount;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 pixel = Texel(tex, tc) * color;
vec3 inverted = 1.0 - pixel.rgb;
vec3 mixed = pixel.rgb * (1.0 - amount) + inverted * amount;
return vec4(mixed, pixel.a);
}
]])
-- Auto hooks new shaders to SHADERS namespace, also adds the NewShader function
return {init = function(gui) gui.SHADERS=shaders gui.NewShader = NewShader end}

View File

@ -1,430 +0,0 @@
-- 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

View File

@ -1,776 +0,0 @@
-------------------------------------------------------------------------------
-- 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,
}

View File

@ -1,362 +0,0 @@
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