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