Initial commit

This commit is contained in:
Ryan Ward 2026-05-06 19:39:08 -07:00
commit ea48705ee4
56 changed files with 13592 additions and 0 deletions

0
.gitignore vendored Normal file
View File

3
.gitmodules vendored Executable file
View File

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

74
CLAUDE.md Normal file
View File

@ -0,0 +1,74 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a Jeopardy-style quiz application built with LÖVE (Love2D) game framework in Lua. The project uses two custom libraries:
- **multi** (v16.3.0) - A multitasking library for Lua with coroutine-based threading, system threads, and priority management
- **gui** (GuiManager) - A UI library built on top of multi for LÖVE-based interfaces
## Running the Application
```bash
# Run with LÖVE
love .
# Run tests
lua main-test.lua
```
## Architecture
### Core Modules
| Module | Path | Purpose |
|--------|------|---------|
| `main.lua` | Root | LÖVE entry point with `love.load()`, `love.update()`, `love.draw()` |
| `board.lua` | Root | Builds the Jeopardy game board UI |
| `task_manager.lua` | Root | Manages game flow and question handling |
| `loader.lua` | Root | Loads question data from YAML files |
| `yaml.lua` | Root | YAML parser |
| `multi/` | Library | Multitasking library with threading support |
| `gui/` | Library | UI framework with frames, buttons, textboxes, images |
### Key Patterns
**multi library usage:**
```lua
multi, thread = require("multi"):init({print = true, priority=true})
GLOBAL, THREAD = require("multi.integration.loveManager"):init()
-- Create threads
multi:newThread("Name", function()
while true do
thread.sleep(1)
-- do work
end
end)
-- Run in love.update()
multi:uManager(dt)
```
**gui library usage:**
```lua
local gui = require("gui")
local color = require("gui.core.color")
gui:setAspectSize(1920, 1080)
local frame = gui:newFrame(x, y, w, h, sx, sy, sw, sh)
frame.color = color.new("#hex")
```
### Data Format
Questions are loaded via `loader.lua` from YAML files with structure:
- `index.yaml` - Contains categories and settings
- `<category>.yaml` - Contains questions for each category
### Integration Points
- `multi.integration.loveManager` - Bridges multi threading with LÖVE's event loop
- `gui.update(dt)` and `gui.draw()` - Called from `love.update()` and `love.draw()`
- UI uses aspect ratio scaling (1920x1080 base)

1
README.md Executable file
View File

@ -0,0 +1 @@
# ascension-rebirth

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

25
anime/index.yaml Executable file
View File

@ -0,0 +1,25 @@
settings:
increment: 100 # how much each tier increases by
start: 100 # starting amount see tier's comment
tiers: 5 # 5 tiers 100, 200, 300, 400, 500 based on above settings
dailyDouble:
enabled: true # should daily double be enabled?
minQuestions: 7 # how many questions need to be answered before a daily double can appear?
count: 2 # how many daily doubles should there be?
categories: # the name of each category should correspond to a yaml file with the same name
- name: shounen # name must be alphanumeric, cannot start with a number or contain special characters "-" and "." are ok if they are not at the beginning
displayName: "Shounen" # if blank will use name
image: "assets/14018-3193093789.gif" # if set will display image instead of name, categories are referenced by name which is required
- name: openings
displayName: "Openings" # if blank will use name
- name: sadness
displayName: "Sadness" # if blank will use name
- name: random
displayName: Random # if blank will use name
- name: you-would-have-though
displayName: "You would have\nthought" # if blank will use name
- name: wtf-is-that
displayName: "Wtf is that!?" # if blank will use name
- name: owo
displayName: "owo" # if blank will use name
# image: "assets/anime-gif-boobs-funny-2710211137.gif"

38
anime/shounen.yaml Executable file
View File

@ -0,0 +1,38 @@
questions: # expects a list equal to the tier
- title: "Name at least 10 of the characters here"
time-limit: 60 # If omitted there is no time limit
answer: 10
image: "anime/assets/shounen_name_characters.png"
# Types: builtin
# - WhatIs (default),
# - MultipleChoice,
# - GuessSound (.wav, .mp3, .ogg, .oga, .ogv),
# - GuessImage (.png, jpg),
# - GuessVideo (.ogv)
template: "one-image" # template to use
- title: "2+2="
time-limit: 10 # If omitted there is no time limit
answer: 4
imageA: ""
imageB: ""
template: "imageword" # template to use
- title: "4+4="
time-limit: 10 # If omitted there is no time limit
answer: 8
# Types: builtin
# - WhatIs (default),
# - MultipleChoice,
# - GuessSound (.wav, .mp3, .ogg, .oga, .ogv),
# - GuessImage (.png, jpg),
# - GuessVideo (.ogv)
template: "whatis" # template to use
- title: "8+8="
time-limit: 10 # If omitted there is no time limit
answer: 16
# Types: builtin
# - WhatIs (default),
# - MultipleChoice,
# - GuessSound (.wav, .mp3, .ogg, .oga, .ogv),
# - GuessImage (.png, jpg),
# - GuessVideo (.ogv)
template: "whatis" # template to use

View File

@ -0,0 +1,70 @@
--[[ Constants
* (all lua builtins that don't allow io/executing code)
color (interface)
gui (interface)
multi (interface bound to a processor) no thread module
callback true/false correct/wrong
]]
local label
local imageHolder
local imageHolder2
local function index(window, q, callback)
frame = window:newFrame(0,0,0,-200,0,.2,1,.8)
frame.visibility = 0
label = window:newTextLabel(" " ..q.title.. " ",0,0,0,0,0,0,1,.2)
label.align = ALIGN_CENTER
label.textColor = color.white
label.color = color.new("#060ce9")
if not q.imageA or q.imageA == "" then
error("Missing 'imageA' field for question!")
end
if not q.imageB or q.imageB == "" then
error("Missing 'imageB' field for question!")
end
imageHolder = frame:newImageLabel(q.imageA)
imageHolder:setAspectSize(imageHolder.imageWidth,imageHolder.imageHeight)
imageHolder:setDualDim(0,0,imageHolder.imageWidth,imageHolder.imageHeight)
imageHolder2 = frame:newImageLabel(q.imageB)
imageHolder2:setAspectSize(imageHolder2.imageWidth,imageHolder2.imageHeight)
imageHolder2:setDualDim(0,0,imageHolder2.imageWidth,imageHolder2.imageHeight)
local correct = window:newTextButton("Correct",0,-200,0,100,0,1,.5)
correct.color = color.new("#52b11b")
local wrong = window:newTextButton("Wrong",0,-200,0,100,.5,1,.5)
wrong.color = color.new("#bd2626")
local skip = window:newTextButton("Skip",0,-100,0,100,.25,1,.5)
skip.color = color.new("#5d5d5d")
window.apply({
centerX = {true},
centerY = {true},
},imageHolder, imageHolder2)
window.apply({
fitFont={},
align=window.ALIGN_CENTER,
OnReleased=function(self)
if self.text == "Skip" then
callback()
return
end
callback(self.text == "Correct")
end,
},correct,wrong,skip)
end
local function update(dt) -- time in seconds that has passed since
_,_,w,h = imageHolder.parent:getAbsolutes()
local x,y,w,h = imageHolder:GetSizeAdjustedToAspectRatio(w,h)
imageHolder:setDualDim(w,h,x,y)
label:fitFont()
end
return {
index = index,
update = update
}

BIN
assets/14018-3193093789.gif Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 962 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

169
assets/convert.lua Executable file
View File

@ -0,0 +1,169 @@
local json = require("json")
function tprint (tbl, indent)
if not indent then indent = 0 end
for k, v in pairs(tbl) do
formatting = string.rep(" ", indent) .. k .. ": "
if type(v) == "table" then
print(formatting)
tprint(v, indent+1)
else
print(formatting .. tostring(v))
end
end
end
function ListItems(dir)
if Dir_Exist(dir) then
temp=List_Files(dir) -- current directory if blank
if GetDirectory(dir)=="C:\\\n" then
a,b=string.find(temp,"C:\\",1,true)
a=a+2
else
a,b=string.find(temp,"..",1,true)
end
temp=string.sub(temp,a+2)
list=StringLineToTable(temp)
temp=string.sub(temp,1,list[#list-2])
slist=lines(temp)
table.remove(slist,1)
table.remove(slist,#slist)
temp={}
temp2={}
for i=1,#slist do
table.insert(temp,string.sub(slist[i],40,-1))
end
return temp
else
print("Directory does not exist")
return nil
end
end
function lines(str)
local t = {}
local function helper(line) table.insert(t, line) return "" end
helper((str:gsub("(.-)\r?\n", helper)))
return t
end
function StringLineToTable(s)
local t = {} -- table to store the indices
local i = 0
while true do
i = string.find(s, "\n", i+1) -- find 'next' newline
if i == nil then return t end
table.insert(t, i)
end
end
function GetDirectory(dir,flop)
s=List_Files(dir)
drive=string.sub(string.match(s,"drive.."),-1)
local t = {} -- table to store the indices
local i = 0
while true do
i = string.find(s, "\n", i+1) -- find 'next' newline
if i == nil then
a,b=string.find(s,drive..":\\",1,true)
main = string.gsub(string.sub(s,a,t[4]), "\n", "")
if flop then
main=main:gsub("%\\", "/")
end
return main
end
table.insert(t, i)
end
end
function List_Files(dir)
if not(dir) then dir="" end
local f = io.popen("dir \""..dir.."\"")
if f then
return f:read("*a")
else
print("failed to read")
end
end
function GetFiles(dir)
local temp2={}
local dirs=ListItems(dir)
for i=1,#dirs do
if Dir_Exist(string.gsub(GetDirectory(dir).."\\"..dirs[i], "\n", "")) then
else
table.insert(temp2,dirs[i])
end
end
return temp2
end
function Dir_Exist(strFolderName)
local fileHandle, strError = io.open(strFolderName.."\\*.*","r")
if fileHandle ~= nil then
io.close(fileHandle)
return true
else
if string.match(strError,"No such file or directory") then
return false
else
return true
end
end
end
function GetDirectories(dir)
temp2={}
dirs=ListItems(dir)
for i=1,#dirs do
if Dir_Exist(string.gsub(GetDirectory(dir).."\\"..dirs[i], "\n", "")) then
table.insert(temp2,dirs[i])
end
end
return temp2
end
local id = 0
local cards = {}
for _, dir in pairs(GetDirectories("cards")) do
for _, card in pairs(GetFiles("cards/"..dir)) do
id = id + 1
print("Processing card: cards/"..dir.."/"..card)
table.insert(cards,{
ID = id,
UUID = "", -- Generated during game
Name = (card:gsub("_"," "):gsub(".png","")),
Source = "assets/cards/" .. dir .. "/" .. card,
Count = 0,
Default_Pack = dir,
Rune_Cost = 0,
Attack_Cost = 0,
Insight_Cost = 0,
Night = false,
Day = false,
Honor = 0,
Type = {
Base = "Hero/Construct/Monster/Dreamscape/Temple",
Faction = "Mechana,Void,Enlightened,Lifebound",
Token = false,
Ongoing = false,
Transformation_Source = "",
},
Unbanishable = false,
Effects = {
"Gain(#,'Rune,Attack,Insight,Honor',target)",
"Draw(#)",
"Shuffle(#)",
"Discard(#, target)",
"AdjustCost(#,'Rune,Attack,Insight',target)",
"Aquire(#,'Rune,Attack,Insight')",
"XasX('Rune,Attack,Insight', 'Rune,Attack,Insight')",
"Temple(type)",
"Banish(#, target)",
"TopDeck(#,target)"
}
})
end
end
json.encode_file("card_data/cards.json", cards)

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

400
assets/json.lua Executable file
View File

@ -0,0 +1,400 @@
--
-- json.lua
--
-- Copyright (c) 2020 rxi
--
-- Permission is hereby granted, free of charge, to any person obtaining a copy of
-- this software and associated documentation files (the "Software"), to deal in
-- the Software without restriction, including without limitation the rights to
-- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-- of the Software, and to permit persons to whom the Software is furnished to do
-- so, subject to the following conditions:
--
-- The above copyright notice and this permission notice shall be included in all
-- copies or substantial portions of the Software.
--
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-- SOFTWARE.
--
local json = { _version = "0.1.2" }
-------------------------------------------------------------------------------
-- Encode
-------------------------------------------------------------------------------
local encode
local escape_char_map = {
[ "\\" ] = "\\",
[ "\"" ] = "\"",
[ "\b" ] = "b",
[ "\f" ] = "f",
[ "\n" ] = "n",
[ "\r" ] = "r",
[ "\t" ] = "t",
}
local escape_char_map_inv = { [ "/" ] = "/" }
for k, v in pairs(escape_char_map) do
escape_char_map_inv[v] = k
end
local function escape_char(c)
return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte()))
end
local function encode_nil(val)
return "null"
end
local function encode_table(val, stack)
local res = {}
stack = stack or {}
-- Circular reference?
if stack[val] then error("circular reference") end
stack[val] = true
if rawget(val, 1) ~= nil or next(val) == nil then
-- Treat as array -- check keys are valid and it is not sparse
local n = 0
for k in pairs(val) do
if type(k) ~= "number" then
error("invalid table: mixed or invalid key types")
end
n = n + 1
end
if n ~= #val then
error("invalid table: sparse array")
end
-- Encode
for i, v in ipairs(val) do
table.insert(res, encode(v, stack))
end
stack[val] = nil
return "[" .. table.concat(res, ",") .. "]"
else
-- Treat as an object
for k, v in pairs(val) do
if type(k) ~= "string" then
error("invalid table: mixed or invalid key types")
end
table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
end
stack[val] = nil
return "{" .. table.concat(res, ",") .. "}"
end
end
local function encode_string(val)
return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
end
local function encode_number(val)
-- Check for NaN, -inf and inf
if val ~= val or val <= -math.huge or val >= math.huge then
error("unexpected number value '" .. tostring(val) .. "'")
end
return string.format("%.14g", val)
end
local type_func_map = {
[ "nil" ] = encode_nil,
[ "table" ] = encode_table,
[ "string" ] = encode_string,
[ "number" ] = encode_number,
[ "boolean" ] = tostring,
}
encode = function(val, stack)
local t = type(val)
local f = type_func_map[t]
if f then
return f(val, stack)
end
error("unexpected type '" .. t .. "'")
end
function json.encode(val)
return ( encode(val) )
end
function json.encode_file(file, val)
local f = io.open(file, "w")
f:write(json.encode(val))
f:close()
end
function json.decode_file(file)
local f = io.open(file, "r")
local str = f:read("*a")
f:close()
return json.decode(str)
end
-------------------------------------------------------------------------------
-- Decode
-------------------------------------------------------------------------------
local parse
local function create_set(...)
local res = {}
for i = 1, select("#", ...) do
res[ select(i, ...) ] = true
end
return res
end
local space_chars = create_set(" ", "\t", "\r", "\n")
local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
local literals = create_set("true", "false", "null")
local literal_map = {
[ "true" ] = true,
[ "false" ] = false,
[ "null" ] = nil,
}
local function next_char(str, idx, set, negate)
for i = idx, #str do
if set[str:sub(i, i)] ~= negate then
return i
end
end
return #str + 1
end
local function decode_error(str, idx, msg)
local line_count = 1
local col_count = 1
for i = 1, idx - 1 do
col_count = col_count + 1
if str:sub(i, i) == "\n" then
line_count = line_count + 1
col_count = 1
end
end
error( string.format("%s at line %d col %d", msg, line_count, col_count) )
end
local function codepoint_to_utf8(n)
-- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
local f = math.floor
if n <= 0x7f then
return string.char(n)
elseif n <= 0x7ff then
return string.char(f(n / 64) + 192, n % 64 + 128)
elseif n <= 0xffff then
return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
elseif n <= 0x10ffff then
return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
f(n % 4096 / 64) + 128, n % 64 + 128)
end
error( string.format("invalid unicode codepoint '%x'", n) )
end
local function parse_unicode_escape(s)
local n1 = tonumber( s:sub(1, 4), 16 )
local n2 = tonumber( s:sub(7, 10), 16 )
-- Surrogate pair?
if n2 then
return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
else
return codepoint_to_utf8(n1)
end
end
local function parse_string(str, i)
local res = ""
local j = i + 1
local k = j
while j <= #str do
local x = str:byte(j)
if x < 32 then
decode_error(str, j, "control character in string")
elseif x == 92 then -- `\`: Escape
res = res .. str:sub(k, j - 1)
j = j + 1
local c = str:sub(j, j)
if c == "u" then
local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1)
or str:match("^%x%x%x%x", j + 1)
or decode_error(str, j - 1, "invalid unicode escape in string")
res = res .. parse_unicode_escape(hex)
j = j + #hex
else
if not escape_chars[c] then
decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string")
end
res = res .. escape_char_map_inv[c]
end
k = j + 1
elseif x == 34 then -- `"`: End of string
res = res .. str:sub(k, j - 1)
return res, j + 1
end
j = j + 1
end
decode_error(str, i, "expected closing quote for string")
end
local function parse_number(str, i)
local x = next_char(str, i, delim_chars)
local s = str:sub(i, x - 1)
local n = tonumber(s)
if not n then
decode_error(str, i, "invalid number '" .. s .. "'")
end
return n, x
end
local function parse_literal(str, i)
local x = next_char(str, i, delim_chars)
local word = str:sub(i, x - 1)
if not literals[word] then
decode_error(str, i, "invalid literal '" .. word .. "'")
end
return literal_map[word], x
end
local function parse_array(str, i)
local res = {}
local n = 1
i = i + 1
while 1 do
local x
i = next_char(str, i, space_chars, true)
-- Empty / end of array?
if str:sub(i, i) == "]" then
i = i + 1
break
end
-- Read token
x, i = parse(str, i)
res[n] = x
n = n + 1
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "]" then break end
if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
end
return res, i
end
local function parse_object(str, i)
local res = {}
i = i + 1
while 1 do
local key, val
i = next_char(str, i, space_chars, true)
-- Empty / end of object?
if str:sub(i, i) == "}" then
i = i + 1
break
end
-- Read key
if str:sub(i, i) ~= '"' then
decode_error(str, i, "expected string for key")
end
key, i = parse(str, i)
-- Read ':' delimiter
i = next_char(str, i, space_chars, true)
if str:sub(i, i) ~= ":" then
decode_error(str, i, "expected ':' after key")
end
i = next_char(str, i + 1, space_chars, true)
-- Read value
val, i = parse(str, i)
-- Set
res[key] = val
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "}" then break end
if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
end
return res, i
end
local char_func_map = {
[ '"' ] = parse_string,
[ "0" ] = parse_number,
[ "1" ] = parse_number,
[ "2" ] = parse_number,
[ "3" ] = parse_number,
[ "4" ] = parse_number,
[ "5" ] = parse_number,
[ "6" ] = parse_number,
[ "7" ] = parse_number,
[ "8" ] = parse_number,
[ "9" ] = parse_number,
[ "-" ] = parse_number,
[ "t" ] = parse_literal,
[ "f" ] = parse_literal,
[ "n" ] = parse_literal,
[ "[" ] = parse_array,
[ "{" ] = parse_object,
}
parse = function(str, idx)
local chr = str:sub(idx, idx)
local f = char_func_map[chr]
if f then
return f(str, idx)
end
decode_error(str, idx, "unexpected character '" .. chr .. "'")
end
function json.decode(str)
if type(str) ~= "string" then
error("expected argument of type string, got " .. type(str))
end
local res, idx = parse(str, next_char(str, 1, space_chars, true))
idx = next_char(str, idx, space_chars, true)
if idx <= #str then
decode_error(str, idx, "trailing garbage")
end
return res
end
return json

BIN
assets/placeholder.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

176
board.lua Normal file
View File

@ -0,0 +1,176 @@
local gui = require("gui")
local color = require("gui.core.color")
local loader = require("loader")
local theme = require("gui.core.theme")
local fmt = require("fmt")
local timer = require("utils")
local multi, thread = require("multi"):init()
local timesup = love.audio.newSource("timesup.mp3", "static")
local double = love.audio.newSource("double.mp3", "static")
local boardUpdater = gui:getProcessor():newProcessor("board-updater")
boardUpdater.Start()
-- Create a table to manage GUI elements
local manage = {}
-- Function to resize fonts for all managed elements
local function resizeFonts()
-- Use multi-threading to improve performance
multi:newThread(function()
-- Introduce a small delay to avoid too many iterations at once
thread.skip(2)
-- Iterate over each element in the 'manage' table
for i = 1, #manage do
local elem = manage[i]
-- Check if the current element has a centerFont property
if elem.centerFont then
-- Adjust font size and re-center the text
elem:fitFont(nil, nil, {scale = 2/3})
elem:centerFont()
end
end
end)
end
gui.Events.OnResized(resizeFonts)
local function buildBoard(frame, path)
local data = loader:new(path)
index = data.index
local board, question = frame:newFrame(), frame:newFrame()
board:fullFrame()
question:fullFrame()
question.visible = false
question.color = color.new("#060ce9")
local tiers = index.settings.tiers or 5
local start = index.settings.start or 100
local inc = index.settings.increment or 100
for cat,v in pairs(index.categories) do
local c
if v.image then
c = board:newImageLabel(v.image, 0, 0, 0, 0,(1/#index.categories)*(cat-1),0,1/#index.categories,1/(tiers+1))
else
c = board:newTextLabel(v.displayName or v.name,0,0,0,0,(1/#index.categories)*(cat-1),0,1/#index.categories,1/(tiers+1))
c.align = gui.ALIGN_CENTER
c.textColor = color.new("#ffffff")
c.color = color.new("#060ce9")
end
img = c:newImageButton("assets/placeholder.jpg")
img.visibility = 0
img:OnReleased(function(self)
self.visible = false
end)
img:fullFrame()
table.insert(manage,c)
for tier = 1,tiers do
local t = board:newTextButton("$" .. start + inc*(tier-1),0,0,0,0,(1/#index.categories)*(cat-1),1/(tiers+1)*tier,(1/#index.categories),1/(tiers+1))
t.textColor = color.new("#9b9024")
t.align = gui.ALIGN_CENTER
t.color = color.new("#060ce9")
t.category = v.name
t.index = tier
t.price = start + inc*(tier-1)
t:OnReleased(boardUpdater:newFunction(function(self)
if self.text == "" then return end
if index.categories[cat].questions == nil then fmt.Printf("Question not defined: File: %v Category: %v - %v\n",path,index.categories[cat].name,start + inc*(tier-1)) return end
local q = index.categories[cat].questions[self.index]
if q == nil then fmt.Printf("Question contains no data: File: %v Category: %v Tier: %v\n",path,index.categories[cat].name,start + inc*(tier-1)) return end
self.textVisibility = 0
local template = LoadTemplate(q.template, path)
question.visible = true
local player = GetActivePlayer()
local tm
local stop
fmt.Printf("Question: %v \nAnswer: %v\n",q["title"],q["answer"])
if q["time-limit"] then
tm = timer.startTimer({duration = q["time-limit"]})
tm.OnStop(function()
-- Make sound? Subtract if daily double
if stop then return end
timesup:play()
end)
end
template.index(question, q, function(ans)
player = GetActivePlayer()
tm:Cleanup()
stop = true
if ans then
player:Add(self.price)
question.visible = false
elseif ans == false then
player:Add(-self.price)
else
question.visible = false
end -- nil is a valid option where you weren't right or wrong, you just skipped
self.text = ""
end)
boardUpdater:newThread("QuestionUpdater",function()
while true do
template.update(dt)
thread.yield()
if self.text == "" then return end
end
end)
end))
table.insert(manage,t)
end
end
resizeFonts()
resizeFonts()
end
local qUpdater = gui:getProcessor():newProcessor("question-updater")
qUpdater.Start()
function LoadTemplate(name, path)
if love.filesystem.getInfo("templates/" .. name .. ".lua") then
data = love.filesystem.read("templates/" .. name .. ".lua")
elseif love.filesystem.getInfo(path .."/templates/" .. name .. ".lua") then
data = love.filesystem.read(path .."/templates/" .. name .. ".lua")
end
local template, err = loadstring(data)
if err ~= nil then
error(err)
end
local env = { -- lua built-ins except io/os code execution
pairs=pairs,
print=print,
tostring=tostring,
tonumber=tonumber,
type=type,
assert=assert,
ipairs=ipairs,
table=table,
math=math,
string=string,
next=next,
pcall=pcall,
select=select,
xpcall=xpcall,
utf8=utf8,
-- Global vars
color = color,
error = multi.error,
theme = theme,
gui = gui
}
env._G = env
local isoTemplate = multi.isolateFunction(template, env)
return isoTemplate()
end
return {
buildBoard = buildBoard
}

38
conf.lua Executable file
View File

@ -0,0 +1,38 @@
function love.conf(t)
t.identity = nil -- The name of the save directory (string)
t.version = "11.5" -- The LOVE version this game was made for (string)
t.console = true -- Attach a console (boolean, Windows only)
t.window.title = "GuiManagerTest" -- The window title (string)
t.window.icon = nil -- Filepath to an image to use as the window's icon (string)
t.window.width = 1920 -- The window width (number)
t.window.height = 1080 -- The window height (number)
t.window.borderless = false -- Remove all border visuals from the window (boolean)
t.window.resizable = true -- Let the window be user-resizable (boolean)
t.window.minwidth = 1 -- Minimum window width if the window is resizable (number)
t.window.minheight = 1 -- Minimum window height if the window is resizable (number)
t.window.fullscreen = false -- Enable fullscreen (boolean)
t.window.fullscreentype = "desktop" -- Standard fullscreen or desktop fullscreen mode (string)
t.window.vsync = false -- Enable vertical sync (boolean)
t.window.fsaa = 4 -- The number of samples to use with multi-sampled antialiasing (number)
t.window.display = 1 -- Index of the monitor to show the window in (number)
t.window.highdpi = true -- Enable high-dpi mode for the window on a Retina display (boolean)
t.window.srgb = false -- Enable sRGB gamma correction when drawing to the screen (boolean)
t.window.x = nil -- The x-coordinate of the window's position in the specified display (number)
t.window.y = nil -- The y-coordinate of the window's position in the specified display (number)
t.modules.audio = true -- Enable the audio module (boolean)
t.modules.event = true -- Enable the event module (boolean)
t.modules.graphics = true -- Enable the graphics module (boolean)
t.modules.image = true -- Enable the image module (boolean)
t.modules.joystick = true -- Enable the joystick module (boolean)
t.modules.keyboard = true -- Enable the keyboard module (boolean)
t.modules.math = true -- Enable the math module (boolean)
t.modules.mouse = true -- Enable the mouse module (boolean)
t.modules.physics = true -- Enable the physics module (boolean)
t.modules.sound = true -- Enable the sound module (boolean)
t.modules.system = true -- Enable the system module (boolean)
t.modules.timer = true -- Enable the timer module (boolean)
t.modules.window = true -- Enable the window module (boolean)
t.modules.thread = true -- Enable the thread module (boolean)
end

BIN
double.mp3 Normal file

Binary file not shown.

355
fmt.lua Normal file
View File

@ -0,0 +1,355 @@
--[[
fmt.lua - Go's fmt.Printf() style formatting for Lua
Supported verbs:
%v - default format (tostring)
%T - type of the value
%t - boolean (true/false)
%d - integer (decimal)
%b - integer (binary)
%o - integer (octal)
%x - integer (hex, lowercase)
%X - integer (hex, uppercase)
%e - float (scientific, lowercase)
%E - float (scientific, uppercase)
%f - float (decimal point)
%g - float (%e for large, %f otherwise)
%G - float (%E for large, %f otherwise)
%s - string
%q - quoted string (Lua-escaped)
%c - character (from integer codepoint)
%% - literal percent sign
Width & precision:
%8d - right-aligned, width 8
%-8d - left-aligned, width 8
%08d - zero-padded, width 8
%8.2f - width 8, 2 decimal places
%.5s - truncate string to 5 chars
%*d - width from next argument
%.*f - precision from next argument
--]]
local fmt = {}
-- ── helpers ──────────────────────────────────────────────────────────────────
local function to_int(v)
local n = math.tointeger and math.tointeger(v) or (type(v) == "number" and math.floor(v) or nil)
if n == nil then
error(string.format("fmt: expected integer, got %s (%s)", type(v), tostring(v)), 3)
end
return n
end
local function to_num(v)
if type(v) ~= "number" then
error(string.format("fmt: expected number, got %s (%s)", type(v), tostring(v)), 3)
end
return v
end
-- Convert integer to binary string
local function to_binary(n)
n = to_int(n)
if n == 0 then return "0" end
local neg = n < 0
if neg then n = -n end
local bits = {}
while n > 0 do
table.insert(bits, 1, n % 2)
n = math.floor(n / 2)
end
local s = table.concat(bits)
return neg and "-" .. s or s
end
-- Apply width/alignment padding
local function apply_width(s, width, left_align, zero_pad, is_numeric)
if not width or width == 0 or #s >= width then return s end
local pad_char = (zero_pad and is_numeric) and "0" or " "
local pad = string.rep(pad_char, width - #s)
if zero_pad and is_numeric then
-- keep sign before zeros: "-007"
if s:sub(1,1) == "-" then
return "-" .. pad .. s:sub(2)
end
return pad .. s
end
return left_align and (s .. pad) or (pad .. s)
end
-- Apply precision to a string (truncation)
local function apply_str_precision(s, prec)
if prec and #s > prec then
return s:sub(1, prec)
end
return s
end
-- Quoted / escaped string (like Go %q)
local function quote_string(s)
s = s:gsub('\\', '\\\\')
s = s:gsub('"', '\\"')
s = s:gsub('\n', '\\n')
s = s:gsub('\r', '\\r')
s = s:gsub('\t', '\\t')
s = s:gsub('%z', '\\0')
return '"' .. s .. '"'
end
-- ── core formatter ────────────────────────────────────────────────────────────
--[[
fmt.Sprintf(format, ...) string
Returns the formatted string without printing it.
--]]
function fmt.Sprintf(format, ...)
local args = { ... }
local ai = 1 -- argument index
local out = {} -- output buffer
local i = 1
local len = #format
while i <= len do
local c = format:sub(i, i)
if c ~= "%" then
out[#out+1] = c
i = i + 1
else
i = i + 1
if i > len then
error("fmt: trailing '%' in format string", 2)
end
-- %% literal
if format:sub(i, i) == "%" then
out[#out+1] = "%"
i = i + 1
else
-- ── parse flags ───────────────────────────────────────────
local left_align = false
local zero_pad = false
local plus_sign = false
local space_sign = false
local hash_flag = false
while i <= len do
local f = format:sub(i, i)
if f == "-" then left_align = true
elseif f == "0" then zero_pad = true
elseif f == "+" then plus_sign = true
elseif f == " " then space_sign = true
elseif f == "#" then hash_flag = true
else break
end
i = i + 1
end
-- ── parse width ───────────────────────────────────────────
local width = nil
if i <= len and format:sub(i, i) == "*" then
width = to_int(args[ai]); ai = ai + 1
i = i + 1
else
local w = format:match("^%d+", i)
if w then width = tonumber(w); i = i + #w end
end
-- ── parse precision ───────────────────────────────────────
local prec = nil
if i <= len and format:sub(i, i) == "." then
i = i + 1
if i <= len and format:sub(i, i) == "*" then
prec = to_int(args[ai]); ai = ai + 1
i = i + 1
else
local p = format:match("^%d*", i)
prec = tonumber(p) or 0
i = i + #p
end
end
-- ── parse verb ────────────────────────────────────────────
if i > len then
error("fmt: missing verb in format string", 2)
end
local verb = format:sub(i, i)
i = i + 1
local arg = args[ai]; ai = ai + 1
local s = ""
local is_numeric = false
-- %v default
if verb == "v" then
s = tostring(arg)
-- %T type
elseif verb == "T" then
s = type(arg)
-- %t boolean
elseif verb == "t" then
s = arg and "true" or "false"
-- %d decimal integer
elseif verb == "d" then
is_numeric = true
local n = to_int(arg)
s = tostring(math.abs(n))
if prec then s = string.rep("0", math.max(0, prec - #s)) .. s end
if n < 0 then s = "-" .. s
elseif plus_sign then s = "+" .. s
elseif space_sign then s = " " .. s
end
-- %b binary
elseif verb == "b" then
is_numeric = true
s = to_binary(arg)
if hash_flag then s = "0b" .. s end
-- %o octal
elseif verb == "o" then
is_numeric = true
local n = to_int(arg)
local neg = n < 0
s = string.format("%o", math.abs(n))
if prec then s = string.rep("0", math.max(0, prec - #s)) .. s end
if hash_flag and s:sub(1,1) ~= "0" then s = "0" .. s end
if neg then s = "-" .. s end
-- %x hex lowercase
elseif verb == "x" then
is_numeric = true
local n = to_int(arg)
local neg = n < 0
s = string.format("%x", math.abs(n))
if prec then s = string.rep("0", math.max(0, prec - #s)) .. s end
if hash_flag then s = "0x" .. s end
if neg then s = "-" .. s end
-- %X hex uppercase
elseif verb == "X" then
is_numeric = true
local n = to_int(arg)
local neg = n < 0
s = string.format("%X", math.abs(n))
if prec then s = string.rep("0", math.max(0, prec - #s)) .. s end
if hash_flag then s = "0X" .. s end
if neg then s = "-" .. s end
-- %e scientific lowercase
elseif verb == "e" then
is_numeric = true
local p = prec ~= nil and prec or 6
s = string.format("%." .. p .. "e", to_num(arg))
if plus_sign and arg >= 0 then s = "+" .. s end
-- %E scientific uppercase
elseif verb == "E" then
is_numeric = true
local p = prec ~= nil and prec or 6
s = string.format("%." .. p .. "E", to_num(arg))
if plus_sign and arg >= 0 then s = "+" .. s end
-- %f decimal float
elseif verb == "f" then
is_numeric = true
local p = prec ~= nil and prec or 6
s = string.format("%." .. p .. "f", to_num(arg))
if plus_sign and arg >= 0 then s = "+" .. s end
if space_sign and arg >= 0 then s = " " .. s end
-- %g / %G shortest float representation
elseif verb == "g" or verb == "G" then
is_numeric = true
local p = prec ~= nil and prec or -1
local n = to_num(arg)
local abs_n = math.abs(n)
if p == -1 then
-- mimic Go: use %e if exponent < -4 or >= precision(default 6)
if abs_n ~= 0 and (abs_n < 1e-4 or abs_n >= 1e6) then
s = string.format(verb == "G" and "%.6E" or "%.6e", n)
else
s = string.format("%.6g", n)
if verb == "G" then s = s:upper() end
end
else
s = string.format("%." .. p .. (verb == "G" and "G" or "g"), n)
end
if plus_sign and n >= 0 then s = "+" .. s end
-- %s string
elseif verb == "s" then
s = tostring(arg)
s = apply_str_precision(s, prec)
-- %q quoted string
elseif verb == "q" then
s = quote_string(tostring(arg))
-- %c character
elseif verb == "c" then
local n = to_int(arg)
-- utf8.char available in Lua 5.3+
if utf8 then
s = utf8.char(n)
else
s = string.char(n) -- ASCII only fallback
end
else
error(string.format("fmt: unknown verb %%%s", verb), 2)
end
-- Apply width padding
s = apply_width(s, width, left_align, zero_pad, is_numeric)
out[#out+1] = s
end
end
end
return table.concat(out)
end
--[[
fmt.Printf(format, ...)
Prints to stdout (no trailing newline, just like Go).
--]]
function fmt.Printf(format, ...)
io.write(fmt.Sprintf(format, ...))
end
--[[
fmt.Println(...)
Prints args separated by spaces with a trailing newline.
--]]
function fmt.Println(...)
local parts = {}
for i = 1, select("#", ...) do
parts[i] = tostring(select(i, ...))
end
print(table.concat(parts, " "))
end
--[[
fmt.Fprintf(file, format, ...)
Writes formatted output to a file handle.
--]]
function fmt.Fprintf(file, format, ...)
file:write(fmt.Sprintf(format, ...))
end
--[[
fmt.Errorf(format, ...) string
Returns a formatted error string (for use with error()).
--]]
function fmt.Errorf(format, ...)
return fmt.Sprintf(format, ...)
end
return fmt

BIN
font/gyparody.ttf Executable file

Binary file not shown.

40
gui/README.md Executable file
View File

@ -0,0 +1,40 @@
# 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~~ ✔️

458
gui/addons/gifloader.lua Executable file
View File

@ -0,0 +1,458 @@
-- 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
-- USAGE:
--[[
local GifLoader = require("gifloader")
function love.load()
myGif = GifLoader.load("animation.gif")
myGif.loop = true
end
function love.update(dt)
GifLoader.update(myGif, dt)
end
function love.draw()
GifLoader.draw(myGif, 100, 100)
-- Draw scaled
GifLoader.draw(myGif, 300, 100, 0, 2, 2)
end
]]

0
gui/addons/init.lua Executable file
View File

43
gui/addons/players.lua Normal file
View File

@ -0,0 +1,43 @@
local gui = require("gui")
local theme = require("gui.core.theme")
local color = require("gui.core.color")
local multi, thread = require("multi"):init()
require("gui.addons.system")
local proc = gui:newProcessor()
function gui:newVideoPlayer(source, x, y, w, h, sx, sy, sw, sh)
local window = gui:newWindow(x, y, w, h, source, true, theme:new({
primary = "#000000",
primaryDark = "#10465c",
primaryText = "#ffffff"
}))
local video = window:newVideo(source, 0, 0, 0, 0, 0, .05, 1, .75)
local play_pause = window:newImageButton("gui/assets/play.png",0,0,0,0,.45,.82,0,.175)
local seek = window:newFrame(0,0,0,0,0,.8,1,.015)
seek.color = color.new("#3c434c")
local seeker = seek:newFrame(0,0,0,0,0,.1,0,.8)
seeker.drawBorder = false
seeker.color = color.new("#0c278a")
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)
local length = video:getDuration()
proc:newThread(function()
while true do
thread.yield()
seeker:setDualDim(nil,nil,nil,nil,nil,nil,video:tell()/length)
end
end)
-- print()
end

115
gui/addons/probe.lua Executable file
View 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

950
gui/addons/system.lua Executable file
View File

@ -0,0 +1,950 @@
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
-- ── window constructor (unchanged from original) ──────────────────────────────
local windowCount = 0
function gui:newWindow(x, y, w, h, 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)
header:setRoundness(10, 10, nil, "top")
local window = header:newFrame(0, 35, 0, h - 35, 0, 0, 1)
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.align = gui.ALIGN_CENTER
X.color = color.red
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 = function() return window end % X.OnPressed
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
-- ── scroll frame (unchanged from original) ────────────────────────────────────
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
-- ── 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, "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.addons.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
gui/assets/play.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

1
gui/changes.md Executable file
View File

@ -0,0 +1 @@
#

26
gui/core/canvas.lua Executable file
View 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 Executable file

File diff suppressed because it is too large Load Diff

91
gui/core/simulate.lua Executable file
View File

@ -0,0 +1,91 @@
local gui = require("gui")
local multi, thread = require("multi"):init()
local transition = require("gui.elements.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 Executable file
View 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

View File

@ -0,0 +1,866 @@
# GUI Library Documentation
A component-based UI framework for LÖVE2D (Love2D) built on top of the `multi` concurrency library. The library provides a scene graph with dual-dimension layout, event-driven input handling, and a rich set of built-in element types.
---
## Table of Contents
1. [Setup & Initialization](#setup--initialization)
2. [Core Concepts](#core-concepts)
- [The Scene Graph](#the-scene-graph)
- [Dual-Dimension Layout (DualDim)](#dual-dimension-layout-dualdim)
- [Element Types (Bitmask)](#element-types-bitmask)
- [Form Factors](#form-factors)
3. [Creating Elements](#creating-elements)
- [Frames](#frames)
- [Text Labels](#text-labels)
- [Text Buttons](#text-buttons)
- [Text Boxes (Input)](#text-boxes-input)
- [Image Labels](#image-labels)
- [Image Buttons](#image-buttons)
- [Videos](#videos)
4. [Layout & Positioning](#layout--positioning)
5. [Events & Connections](#events--connections)
- [Global GUI Events](#global-gui-events)
- [Per-Element Events](#per-element-events)
- [Hot Keys](#hot-keys)
6. [Element Methods](#element-methods)
- [Positioning & Sizing](#positioning--sizing)
- [Visual Properties](#visual-properties)
- [Hierarchy & Parenting](#hierarchy--parenting)
- [Utilities](#utilities)
7. [Text Elements](#text-elements)
- [Font Management](#font-management)
- [Text Box Internals](#text-box-internals)
8. [Image Elements](#image-elements)
9. [Clipping & Scissor](#clipping--scissor)
10. [Roundness & Shape](#roundness--shape)
11. [Aspect Ratio & Resize Handling](#aspect-ratio--resize-handling)
12. [The `apply` Helper](#the-apply-helper)
13. [Tagging System](#tagging-system)
14. [Cloning Elements](#cloning-elements)
15. [Processors & Threading](#processors--threading)
16. [Drawing Internals](#drawing-internals)
17. [Virtual GUI](#virtual-gui)
---
## Setup & Initialization
```lua
local gui = require("path.to.gui")
```
The library self-initializes on `require`. It hooks into LÖVE's callback system automatically (quit, resize, mouse, keyboard, touch, gamepad, etc.) and starts its internal update and draw processors.
In your `love.update` and `love.draw`:
```lua
function love.update(dt)
gui.update(dt)
end
function love.draw()
gui.draw()
end
```
> **Note:** The library hooks LÖVE callbacks via a `Hook` function that wraps any pre-existing handler you define. Define your own `love.*` callbacks **before** `require`-ing the library, or they will be chained automatically.
---
## Core Concepts
### The Scene Graph
The library maintains two root nodes:
| Root | Description |
|---|---|
| `gui` | The main scene root. All elements created with `gui:newXxx()` are parented here by default. |
| `gui.virtual` | A secondary root for off-screen or hidden elements. Children here are not drawn but still have their absolute positions updated. |
Elements form a tree. Every element has a `parent`, a `children` table, and inherits methods from `gui` via `__index`.
### Dual-Dimension Layout (DualDim)
Every element stores its position and size as a **dual dimension**: a combination of a scale component (relative to the parent) and an offset component (absolute pixels).
```
actualX = parent.w * scale.pos.x + offset.pos.x + parent.x
actualY = parent.h * scale.pos.y + offset.pos.y + parent.y
actualW = parent.w * scale.size.x + offset.size.x
actualH = parent.h * scale.size.y + offset.size.y
```
Constructor signature for `newDualDim` / all `newXxx` creation functions:
```
x, y, w, h -- pixel offset for position and size
sx, sy, sw, sh -- scale (01) for position and size
```
Examples:
```lua
-- 200×100 box at pixel position (50, 50):
gui:newFrame(50, 50, 200, 100)
-- Full-screen frame (uses scale only):
local f = gui:newFrame()
f:fullFrame() -- sets scale size to (1,1) and offset to (0,0,0,0)
-- Half-width, 40px tall, starting at 25% from left:
gui:newFrame(0, 100, 0, 40, 0.25, 0, 0.5, 0)
```
Retrieve the computed screen-space rectangle at any time:
```lua
local x, y, w, h = element:getAbsolutes()
```
### Element Types (Bitmask)
Types are stored as a bitmask so an element can have multiple roles:
| Constant | Value | Meaning |
|---|---|---|
| `gui.TYPE_FRAME` | 0 | Basic container |
| `gui.TYPE_IMAGE` | 1 | Renders an image |
| `gui.TYPE_TEXT` | 2 | Renders text |
| `gui.TYPE_BOX` | 4 | Text input cursor/selection overlay |
| `gui.TYPE_VIDEO` | 8 | Renders a video |
| `gui.TYPE_BUTTON` | 16 | Interactive button (sets hand cursor) |
| `gui.TYPE_ANIM` | 32 | Animation / spritesheet |
Test membership:
```lua
if element:hasType(gui.TYPE_TEXT) then ... end
if element:hasType(gui.TYPE_TEXT + gui.TYPE_BOX) then ... end -- is a text box
```
### Form Factors
Controls the shape used for both fills and hit-testing:
| Constant | Shape |
|---|---|
| `gui.FORM_RECTANGLE` | Rounded or plain rectangle (default) |
| `gui.FORM_CIRCLE` | Circle; `w` and `h` are set to `2*r` |
| `gui.FORM_ARC` | Arc segment |
---
## Creating Elements
All creation functions are called on a **parent** element (or on `gui` itself for top-level elements). The new element is automatically inserted into the parent's `children` table.
### Frames
A plain container with a background fill and optional border.
```lua
local frame = parent:newFrame(x, y, w, h, sx, sy, sw, sh)
```
A **virtual frame** is parented to `gui.virtual` regardless of the caller:
```lua
local vframe = parent:newVirtualFrame(x, y, w, h, sx, sy, sw, sh)
```
A **visual frame** is a regular frame tagged `"visual"`. Mouse events on it and its descendants are suppressed (useful for purely decorative overlays):
```lua
local overlay = parent:newVisualFrame(x, y, w, h, sx, sy, sw, sh)
```
### Text Labels
A non-interactive text element.
```lua
local label = parent:newTextLabel("Hello world", x, y, w, h, sx, sy, sw, sh)
```
### Text Buttons
A text element that fires pointer events and shows a hand cursor on hover.
```lua
local btn = parent:newTextButton("Click me", x, y, w, h, sx, sy, sw, sh)
btn.OnPressed(function(self, x, y) print("pressed!") end)
```
### Text Boxes (Input)
A single-line text input field.
```lua
local box = parent:newTextBox("default text", x, y, w, h, sx, sy, sw, sh)
box.OnReturn(function(self, text) print("Submitted:", text) end)
```
Keyboard navigation, backspace/delete, selection (click-drag or Ctrl+A), copy/paste/cut, and undo/redo are all handled automatically when the box has focus.
### Image Labels
A non-interactive image element.
```lua
local img = parent:newImageLabel("path/to/image.png", x, y, w, h, sx, sy, sw, sh)
```
GIF files are detected automatically by the `.gif` extension and animated.
### Image Buttons
An image element that fires pointer events and shows a hand cursor on hover.
```lua
local ibtn = parent:newImageButton("icon.png", x, y, w, h, sx, sy, sw, sh)
ibtn.OnPressed(function(self, x, y) print("image clicked") end)
```
### Videos
Wraps a LÖVE `Video` object.
```lua
local vid = parent:newVideo("clip.ogv", x, y, w, h, sx, sy, sw, sh)
vid:play()
vid.OnVideoFinished(function(self) print("done") end)
```
Video methods:
| Method | Description |
|---|---|
| `vid:setVideo(path_or_video)` | Load or swap the video source |
| `vid:play()` | Start playback |
| `vid:pause()` | Pause without rewinding |
| `vid:stop()` | Pause and rewind |
| `vid:rewind()` | Seek to start |
| `vid:seek(seconds)` | Jump to position |
| `vid:tell()` | Return current playback position (seconds) |
| `vid:getDuration()` | Return total duration (seconds) |
| `vid:setVolume(vol)` | Set audio volume (01) |
| `vid:getVideo()` | Return the underlying LÖVE Video object |
---
## Layout & Positioning
### Setting the Dual Dimension
```lua
-- Fires OnSizeChanged
element:setDualDim(x, y, w, h, sx, sy, sw, sh)
-- Silent version (no event)
element:rawSetDualDim(x, y, w, h, sx, sy, sw, sh)
-- Read back
local x, y, w, h, sx, sy, sw, sh = element:getDualDim()
```
Pass `nil` for any argument to keep the current value.
### Moving and Resizing
```lua
-- Delta move (fires OnPositionChanged)
element:move(dx, dy)
-- Delta resize (fires OnSizeChanged)
element:size(dw, dh)
-- Move but clamp to parent bounds
element:moveInBounds(dx, dy)
```
### Centering
```lua
element:centerX(true) -- horizontally center within parent
element:centerY(true) -- vertically center within parent
```
These attach internal loops that continuously recompute the offset whenever the element's size or position changes.
### Convenience
```lua
element:fullFrame() -- scale size (1,1), offset (0,0,0,0) — fills parent
```
### Dragging
```lua
element:enableDragging(button) -- button = love mouse button number (1=left, 2=right, …)
element:enableDragging(nil) -- disable dragging
```
While dragging, `OnDragging`, `OnDragStart`, and `OnDragEnd` are fired.
### Z-Order
```lua
element:topStack() -- move to end of parent.children (drawn last = on top)
element:bottomStack() -- move to front of parent.children (drawn first = behind)
```
---
## Events & Connections
Events use the `multi` connection system. Connect a handler by calling the connection as a function:
```lua
element.OnPressed(function(self, x, y, button, istouch, presses)
-- ...
end)
```
Connections support composition:
```lua
-- OR: fires when either fires
(connA + connB)(handler)
-- AND: fires only when both conditions are met
(connA * connB)(handler)
```
### Global GUI Events
These fire for the entire application window regardless of which element is focused.
| Event | LÖVE callback | Arguments |
|---|---|---|
| `gui.Events.OnQuit` | `love.quit` | — |
| `gui.Events.OnDirectoryDropped` | `love.directorydropped` | `dir` |
| `gui.Events.OnDisplayRotated` | `love.displayrotated` | `index, orient` |
| `gui.Events.OnFilesDropped` | `love.filedropped` | `file` |
| `gui.Events.OnFocus` | `love.focus` | `focused` |
| `gui.Events.OnMouseFocus` | `love.mousefocus` | `focused` |
| `gui.Events.OnResized` | `love.resize` | `w, h` |
| `gui.Events.OnVisible` | `love.visible` | `visible` |
| `gui.Events.OnKeyPressed` | `love.keypressed` | `key, scancode, isrepeat` |
| `gui.Events.OnKeyReleased` | `love.keyreleased` | `key, scancode` |
| `gui.Events.OnTextEdited` | `love.textedited` | `text, start, length` |
| `gui.Events.OnTextInputed` | `love.textinput` | `text` |
| `gui.Events.OnMouseMoved` | `love.mousemoved` | `x, y, dx, dy, istouch` |
| `gui.Events.OnMousePressed` | `love.mousepressed` | `x, y, button, istouch, presses` |
| `gui.Events.OnMouseReleased` | `love.mousereleased` | `x, y, button, istouch, presses` |
| `gui.Events.OnWheelMoved` | `love.wheelmoved` | `x, y` |
| `gui.Events.OnTouchMoved` | `love.touchmoved` | `id, x, y, dx, dy, pressure` |
| `gui.Events.OnTouchPressed` | `love.touchpressed` | `id, x, y, dx, dy, pressure` |
| `gui.Events.OnTouchReleased` | `love.touchreleased` | `id, x, y, dx, dy, pressure` |
| `gui.Events.OnGamepadPressed` | `love.gamepadpressed` | `joystick, button` |
| `gui.Events.OnGamepadReleased` | `love.gamepadreleased` | `joystick, button` |
| `gui.Events.OnGamepadAxis` | `love.gamepadaxis` | `joystick, axis, value` |
| `gui.Events.OnJoystickAdded` | `love.joystickadded` | `joystick` |
| `gui.Events.OnJoystickRemoved` | `love.joystickremoved` | `joystick` |
| `gui.Events.OnJoystickHat` | `love.joystickhat` | `joystick, hat, dir` |
| `gui.Events.OnJoystickPressed` | `love.joystickpressed` | `joystick, button` |
| `gui.Events.OnJoystickReleased` | `love.joystickreleased` | `joystick, button` |
| `gui.Events.OnCreated` | internal | `element` — fires when any element is created |
| `gui.Events.OnObjectFocusChanged` | internal | `old, new` — fires when click focus changes |
### Per-Element Events
These are attached to each element instance. All mouse/pointer events are automatically pre-filtered: they only fire when the element is `active` and (for most events) when the pointer is within the element's bounds.
| Event | Fires when… |
|---|---|
| `OnLoad` | (manual) element is "loaded" — user-defined |
| `OnPressed` | pointer pressed **inside** element |
| `OnPressedOuter` | pointer pressed **outside** element |
| `OnReleased` | pointer released **inside** element |
| `OnReleasedOuter` | pointer released **outside** (but was pressed inside) |
| `OnReleasedOther` | pointer released with no relevant press history |
| `OnDragStart` | drag begins (element must have `enableDragging` set) |
| `OnDragging` | pointer moves while dragging |
| `OnDragEnd` | drag ends |
| `OnEnter` | pointer enters the element bounds |
| `OnExit` | pointer leaves the element bounds |
| `OnMoved` | pointer moves while inside (or while dragging) |
| `OnWheelMoved` | scroll wheel moves while pointer is inside element |
| `OnSizeChanged` | `setDualDim` or `size` called |
| `OnPositionChanged` | `setDualDim` or `move` called |
| `OnDestroy` | element is about to be destroyed |
| `OnCreated` | element was created (forwarded from `gui.Events.OnCreated`) |
| `OnReturn` | (text boxes only) Enter/Return key pressed |
| `OnFontUpdated` | (text elements only) font changed via `setFont` |
| `OnVideoFinished` | (video elements only) video reaches its end |
| `OnLeftStickUp/Down/Left/Right` | gamepad left-stick events |
| `OnRightStickUp/Down/Left/Right` | gamepad right-stick events |
#### Hierarchy Mode
By default events fire if another element is not on top. Call:
```lua
element:respectHierarchy(false) -- events will fire regardless
```
to make `OnPressed`, `OnReleased`, `OnEnter`, and `OnMoved` skip when the element is covered by a sibling.
---
### Hot Keys
Register a keyboard shortcut that fires a connection:
```lua
local conn = element:setHotKey({"lctrl", "s"}) -- returns a connection
conn(function(ref) print("Ctrl+S on", ref) end)
```
You may pass an existing connection as the second argument to reuse it.
#### Built-in Hot Keys
| Hot Key | Trigger |
|---|---|
| `gui.HotKeys.OnSelectAll` | Ctrl+A |
| `gui.HotKeys.OnCopy` | Ctrl+C |
| `gui.HotKeys.OnPaste` | Ctrl+V |
| `gui.HotKeys.OnCut` | Ctrl+X |
| `gui.HotKeys.OnUndo` | Ctrl+Z |
| `gui.HotKeys.OnRedo` | Ctrl+Y / Ctrl+Shift+Z |
These are already wired to the currently-focused text box for standard editing operations.
---
## Element Methods
### Positioning & Sizing
| Method | Description |
|---|---|
| `el:getAbsolutes([transform])` | Returns `x, y, w, h` in screen space. Optional `transform` function is applied to each value. |
| `el:setDualDim(x,y,w,h,sx,sy,sw,sh)` | Set layout, fires `OnSizeChanged`. |
| `el:rawSetDualDim(...)` | Set layout, no event. |
| `el:getDualDim()` | Returns all 8 dual-dim components. |
| `el:move(dx, dy)` | Translate by delta, fires `OnPositionChanged`. |
| `el:size(dw, dh)` | Resize by delta, fires `OnSizeChanged`. |
| `el:moveInBounds(dx, dy)` | Translate while keeping element inside parent. |
| `el:fullFrame()` | Fill parent entirely. |
| `el:centerX(bool)` | Auto-center horizontally. |
| `el:centerY(bool)` | Auto-center vertically. |
| `el:getLocalCords(mx, my)` | Convert screen coordinates to element-local coordinates. |
### Visual Properties
| Property | Type | Default | Description |
|---|---|---|---|
| `color` | `{r,g,b}` | `{0.6, 0.6, 0.6}` | Background fill color |
| `borderColor` | `{r,g,b}` | black | Border color |
| `drawBorder` | boolean | `true` | Whether to draw the border |
| `visibility` | number | `1` | Background alpha (01) |
| `rotation` | number | `0` | Rotation in degrees |
| `active` | boolean | `true` | When `false`, element and all descendants ignore input |
| `visible` | boolean | `true` | Controls `getAllChildren` visibility filter |
| `ignore` | boolean | — | When `true`, element is skipped in coverage tests |
Set color (also sets `visibility` if a 4th component is present):
```lua
element:setColor("color", {1, 0, 0, 0.8})
element:setColor("borderColor", {0, 0, 0})
```
Apply a LÖVE shader:
```lua
element.shader = love.graphics.newShader(...)
```
Apply an effect wrapper (called around the draw call):
```lua
element.effect = function(drawFunc)
love.graphics.push()
-- setup
drawFunc()
love.graphics.pop()
end
```
Apply a post-draw hook:
```lua
element.post = function(self)
-- called after drawing, inside the same scissor/shader state
end
```
### Hierarchy & Parenting
| Method | Description |
|---|---|
| `el:setParent(newParent)` | Re-parent element. Pass `nil` to detach. |
| `el:getChildren()` | Returns direct children table. |
| `el:getAllChildren([includeHidden])` | Returns all visible descendants recursively. |
| `el:isDescendantOf(obj)` | Returns `true` if `obj` is an ancestor of `el`. |
| `el:topStack()` | Draw on top of siblings. |
| `el:bottomStack()` | Draw behind siblings. |
| `el:destroy()` | Destroy element, its children, and all connections. |
| `el:removeChildren()` | Destroy all children but leave element itself. |
| `el:isActive()` | `true` if `active` and not parented under `gui.virtual`. |
| `el:isOffScreen()` | `true` if element rect is entirely outside screen bounds. |
### Utilities
| Method | Description |
|---|---|
| `el:hasType(t)` | Bitmask type test. |
| `el:canPress(mx, my)` | `true` if point is inside element (respects clip area). |
| `el:isBeingCovered(mx, my)` | `true` if a sibling is in front of this element at the given point. |
| `el:intersecpt(x, y, w, h)` | Returns intersection rect with a given AABB. |
| `el:newThread(func)` | Spawn a coroutine-style thread scoped to this element. |
| `el:getObjectFocus()` | Returns the currently focused element. |
| `el:getProcessor()` | Returns the internal updater processor. |
---
## Text Elements
All text elements (`newTextLabel`, `newTextButton`, `newTextBox`) inherit from `newTextBase`.
### Properties
| Property | Type | Default | Description |
|---|---|---|---|
| `text` | string | — | Displayed string |
| `textColor` | `{r,g,b}` | black | Text color |
| `font` | Font | 12px default | LÖVE Font object |
| `align` | constant | `ALIGN_LEFT` | `gui.ALIGN_LEFT`, `ALIGN_CENTER`, `ALIGN_RIGHT` |
| `textOffsetX/Y` | number | `0` | Additional pixel offset for text drawing |
| `textScaleX/Y` | number | `1` | Scale applied to text rendering |
| `textShearingFactorX/Y` | number | `0` | Shearing factor for text transform |
| `textVisibility` | number | `1` | Text alpha (01) |
### Font Management
```lua
-- By size (default font)
element:setFont(14)
-- By path and size
element:setFont("fonts/myfont.ttf", 18)
-- By LÖVE font object
element:setFont(love.graphics.newFont("fonts/myfont.ttf", 18))
```
Automatically resize font to fill element bounds:
```lua
-- Binary-search fit between min and max size
element:fitFont(minSize, maxSize, {scale = 1})
-- Returns bestFont, bestSize
```
Center text vertically inside the element:
```lua
element:centerFont(y_offset)
```
Calculate where the top and bottom of rendered text actually are (pixel offsets within element):
```lua
local top, bottom = element:calculateFontOffset(font, adjust)
```
### Text Box Internals
| Property | Description |
|---|---|
| `cur_pos` | Integer cursor position (0 = before first character) |
| `selection` | `{start, stop}` character indices (may be reversed) |
| `bar_show` | `true` when the cursor bar should be visible (blinks via internal thread) |
| `doSelection` | `true` while a drag-selection is in progress |
Methods:
```lua
box:HasSelection() -- returns true/false
box:GetSelection() -- returns start, stop (always start ≤ stop)
box:GetSelectedText() -- returns selected substring
box:ClearSelection() -- clear selection state
```
---
## Image Elements
All image elements (`newImageLabel`, `newImageButton`) inherit from `newImageBase`.
### `setImage`
```lua
-- From a file path (PNG, JPG, etc.)
element:setImage("path/to/image.png")
-- GIF animation (auto-detected by extension)
element:setImage("path/to/anim.gif")
-- From a LÖVE Image object
element:setImage(loveImageObject)
```
### Properties
| Property | Description |
|---|---|
| `imageColor` | Tint color applied when drawing |
| `imageVisibility` | Image alpha (01) |
| `scaleX / scaleY` | Flip/scale. Negative values flip the axis. |
| `quad` | LÖVE Quad used for rendering (sub-region) |
### Flipping
```lua
element:flip(false) -- flip horizontally
element:flip(true) -- flip vertically
```
### Gradient
Apply a gradient as the image of any element:
```lua
element:applyGradient("horizontal", {r,g,b,a}, {r,g,b,a}, ...)
element:applyGradient("vertical", {r,g,b,a}, {r,g,b,a}, ...)
```
### Image Caching
```lua
-- Pre-load a single image into the cache
gui.cacheImage(gui, "path/to/img.png")
-- Pre-load multiple images; reports progress via OnStatus
gui.cacheImage(gui, {"img1.png", "img2.png"})
-- Tile helper: returns imagedata and quad
local imgdata, quad = gui:getTile("sheet.png", tileX, tileY, tileW, tileH)
```
---
## Clipping & Scissor
Clipping is set on a **parent** and affects all descendants:
```lua
parent.clipDescendants = true
```
During each draw pass, the parent propagates its screen-space rectangle to each child's `__variables.clip`. Children then apply LÖVE's scissor test to avoid drawing outside the parent.
---
## Roundness & Shape
```lua
-- Rounded corners
element:setRoundness(rx, ry, segments, side)
-- rx, ry: x/y radius (default 5)
-- segments: arc segments (default 30)
-- side: "top", "bottom", or true (all corners)
-- Directional override
element:setRoundnessDirection(horizontal, vertical)
```
Circle and arc shapes are set at creation time:
```lua
-- Circle
element:makeCircle(x, y, radius, sx, sy, sr, segments)
-- Arc
element:makeArc(arcType, x, y, radius, sx, sy, sr, startAngle, endAngle, segments)
-- arcType: "open", "closed", or "pie" (passed to love.graphics.arc)
-- Angles in radians
```
---
## Aspect Ratio & Resize Handling
Lock the root GUI to a design resolution:
```lua
gui:setAspectSize(1920, 1080) -- set design resolution
gui.aspect_ratio = true -- enable aspect-ratio mode
```
When the window resizes, the library calculates letterbox/pillarbox offsets and adjusts `gui.x`, `gui.y`, `gui.w`, `gui.h` (and the same on `gui.virtual`) so all elements remain proportional.
Disable it:
```lua
gui:setAspectSize(nil, nil)
gui.aspect_ratio = false
```
Utility to compute the scaled size manually:
```lua
local nw, nh, offsetX, offsetY = gui:GetSizeAdjustedToAspectRatio(windowW, windowH)
```
---
## The `apply` Helper
`gui.apply` is a batch property setter that inspects each field name for a prefix:
| Prefix | Meaning |
|---|---|
| `C_` | Connect to the named connection (value = handler function) |
| `I_` | Invoke the named method with args from a table |
| *(none)* | Direct assignment or smart detection (connection vs function vs value) |
```lua
gui.apply({
color = {1, 0, 0},
C_OnPressed = function(self) print("pressed") end,
I_setFont = {"fonts/bold.ttf", 16},
}, buttonA, buttonB, buttonC)
```
---
## Tagging System
Arbitrary string tags can be attached to any element:
```lua
element:setTag("draggable")
element:setTag("ui-panel")
element:hasTag("draggable") -- true / false (direct tag)
element:parentHasTag("ui-panel") -- true if any ancestor has the tag
```
The built-in `"visual"` tag suppresses all mouse event connections:
```lua
local deco = parent:newVisualFrame(...) -- automatically gets "visual" tag
```
---
## Cloning Elements
Deep-copy an element and optionally its connection handlers:
```lua
local copy = element:clone({
copyTo = targetParent, -- parent for the clone (default: gui.virtual)
connections = true, -- also copy connection handlers
})
```
`clone` recurses through all children. Connection handlers from the original are **bound** (not moved) to the clone's connections, so both elements remain independently connected.
---
## Processors & Threading
The library uses two internal processors from the `multi` library:
| Processor | Purpose |
|---|---|
| `updater` | Input hooks, hot keys, text-box blink, video completion, image loading |
| `drawer` | Per-frame draw loop, virtual element position pass |
Create a new processor that participates in `gui.update`:
```lua
local proc = gui:newProcessor("MyProcessor")
-- proc is a multi Processor; attach tasks/loops to it normally
```
Spawn a coroutine thread scoped to an element:
```lua
element:newThread(function(self, thread)
while true do
thread.sleep(1)
print("tick", self.text)
end
end)
```
Attach a per-frame update callback (called every update loop):
```lua
gui:OnUpdate(function(self, dt)
-- called every frame
end)
element:OnUpdate(function(self, dt)
-- called every frame with element as self
end)
```
Create a one-shot or reusable function that runs asynchronously:
```lua
local fn = gui.newFunction(function(arg1, arg2)
-- runs in updater context
end)
fn(arg1, arg2)
```
---
## Drawing Internals
The draw loop iterates `gui:getAllChildren()` each frame and calls `draw_handler` on each element in order (back-to-front).
`draw_handler` does, in order:
1. Compute and cache `child.x/y/w/h` via `getAbsolutes`.
2. Propagate clip rects to descendants if `clipDescendants` is set.
3. Activate shader if present.
4. Apply LÖVE scissor (clip or roundness-based).
5. Fill background with `child.color` and `child.visibility`.
6. Draw border with `child.borderColor`.
7. Handle special roundness sides ("top"/"bottom").
8. Dispatch to type-specific draw functions (video → image → text → box cursor/selection).
9. Call `child:post()` if defined.
10. Remove scissor and shader.
`gui.draw_handler` is exposed publicly so custom renderers can call it directly.
---
## Virtual GUI
`gui.virtual` is a root node whose children are never rendered on screen but still participate in the layout pass (absolute positions are computed). Use it to keep pre-built off-screen components ready to be re-parented:
```lua
-- Create off-screen
local popup = gui.virtual:newFrame(0, 0, 400, 300)
-- Show it by re-parenting
popup:setParent(gui)
-- Hide it again
popup:setParent(gui.virtual)
```
`gui.virtual` shares the same screen dimensions as `gui`, so positions remain correct when an element moves between them.

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

69
gui/elements/init.lua Executable file
View File

@ -0,0 +1,69 @@
local gui = require("gui")
local color = require("gui.core.color")
local theme = require("gui.core.theme")
local transition = require("gui.elements.transitions")
function gui:newMenu(title, sx, position, trans)
if not title then multi.error("Argument 1 string('title') is required") end
if not sx then multi.error("Argument 2 number('sx') is required") end
local position = position or gui.ALIGN_LEFT
local trans = trans or transition.glide
local menu, to, tc, open
if position == gui.ALIGN_LEFT then
menu = self:newFrame(0, 0, 0, 0, -sx, 0, sx, 1)
to = trans(-sx, 0, .25)
tc = trans(0, -sx, .25)
elseif position == gui.ALIGN_CENTER then
menu = self:newFrame(0, 0, 0, 0, .5 -sx/2, 1.1, sx, 1)
to = trans(1.1, 0, .35)
tc = trans(0, 1.1, .35)
elseif position == gui.ALIGN_RIGHT then
menu = self:newFrame(0, 0, 0, 0, 1, 0, sx, 1)
to = trans(1, 1 - sx, .25)
tc = trans(1 - sx, 1, .25)
end
function menu:isOpen()
return open
end
function menu:Open(show)
if show then
if not menu.lock then
menu.lock = true
local t = to()
t.OnStop(function()
open = true
menu.lock = false
end)
t.OnStep(function(p)
if position == gui.ALIGN_CENTER then
menu:setDualDim(nil, nil, nil, nil, nil, p)
else
menu:setDualDim(nil, nil, nil, nil, p)
end
end)
end
else
if not menu.lock then
menu.lock = true
local t = tc()
t.OnStop(function()
open = false
menu.lock = false
end)
t.OnStep(function(p)
if position == gui.ALIGN_CENTER then
menu:setDualDim(nil, nil, nil, nil, nil, p)
else
menu:setDualDim(nil, nil, nil, nil, p)
end
end)
end
end
end
return menu
end

78
gui/elements/transitions.lua Executable file
View File

@ -0,0 +1,78 @@
local gui = require("gui")
local multi, thread = require("multi"):init()
local processor = gui:newProcessor("Transistion 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) -- allow these values to be overridden
if not (st or start) or not (sp or stop) then return multi.error("start and stop must be supplied") end
if start == stop then
local temp = {
OnStep = function() end,
OnStop = multi:newConnection()
}
proc:newTask(function()
temp.OnStop:Fire()
end)
return temp
end
local handle = t.func(t, start, stop, (ti or time) or 1, unpack(args))
return {
OnStep = handle.OnStatus,
OnStop = handle.OnReturn + handle.OnError,
Kill = t.Kill
}
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(fps)
self.fps = fps
end
function c:GetFPS(fps)
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 steps = t.fps*time
local piece = time/steps
local split = stop-start
t.running = true
for i = 0, steps do
if not(t.kill) then
thread.sleep(piece)
thread.pushStatus(start + i*(split/steps),piece*i)
end
end
t.running = false
t.kill = false
end)
return transition

1960
gui/init.lua Executable file

File diff suppressed because it is too large Load Diff

430
gui/yaml/builder.lua Normal file
View 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
View 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
View 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

34
loader.lua Normal file
View File

@ -0,0 +1,34 @@
local yaml = require("yaml")
local loader = {}
loader.__index = loader
function loader:new(path_or_file)
local c = {}
setmetatable(c, loader)
c.source = path_or_file
local file = io.open(path_or_file .. "/index.yaml","r")
if not file then
error("Unable to load file: ".. path_or_file .."/index.yaml")
end
c.index = yaml.parse(file:read("*a"))
for i,v in pairs(c.index.categories) do
local link = io.open(path_or_file .. "/" .. v.name .. ".yaml")
if not link then
print("Error! Cannot find file: " .. path_or_file .."/" .. v.name .. ".yaml")
else
local category = yaml.parse(link:read("*a"))
c.index.categories[i].questions = category.questions
end
end
return c
end
function loader:getCategories()
return self.index.categories
end
function loader:getSettings()
return self.index.settings
end
return loader

59
login_dialog.yaml Normal file
View File

@ -0,0 +1,59 @@
# login_dialog.yaml
type: frame
x: 0
y: 0
scale-size: [0.4, 0.25]
scale-pos: [0.3, 0.2]
color: "#1e1e2e"
border-color: "#4a4a6a"
roundness: 12
children:
- type: label
text: "Sign In"
x: 0
y: 20
scale-size: [1.0, 0.0]
h: 48
align: center
font: 28
text-color: "#cdd6f4"
- type: textbox
x: 30
y: 90
scale-size: [0.85, 0.0]
h: 36
text: ""
color: "#313244"
border-color: "#6c7086"
text-color: "#cdd6f4"
font: 14
on-return: "focusPassword"
- type: textbox
x: 30
y: 140
scale-size: [0.85, 0.0]
h: 36
text: ""
color: "#313244"
border-color: "#6c7086"
text-color: "#cdd6f4"
font: 14
on-return: "submitLogin"
- type: button
text: "Log In"
align: "center"
fit-font: true
x: 30
y: 200
scale-size: [0.2, 0.0]
h: 40
color: "#89b4fa"
text-color: "#1e1e2e"
roundness: 8
on-pressed: "submitLogin"
on-enter: "highlightButton"
on-exit: "unhighlightButton"

258
main.lua Executable file
View File

@ -0,0 +1,258 @@
local gui, color, theme, utils, board, yaml, loader, system, elements, scoreUpdater
local activePlayer
function GetActivePlayer()
return activePlayer.link
end
function love.filedropped(file)
file:open("r")
local data = file:read()
print("Load file? " .. file:getFilename())
end
function init()
multi, thread = require("multi"):init({priority=true})
multi.setClock(require("socket").gettime) -- When on linux os.clock doesn't reture actual seconds the program has elapsed for
GLOBAL, THREAD = require("multi.integration.loveManager"):init()
gui = require("gui")
color = require("gui.core.color")
theme = require("gui.core.theme")
utils = require("utils")
board = require("board")
yaml = require("yaml")
loader = require("loader")
system = require("gui.addons.system")
elements = require("gui.elements")
scoreUpdater = gui:getProcessor():newProcessor("score-updater")
scoreUpdater.Start()
end
function ScoreBoard(frame, x, y, w, h, sx, sy, sw, sh)
local scoreboard = {}
-- Colors
local C_BG_PANEL = color.new("#1a1a2e")
local C_BG_HEADER = color.new("#16213e")
local C_ACCENT = color.new("#e94560")
local C_ROW_TOP = color.new("#1c2641")
local C_ROW_NORM = color.new("#121226")
local C_BORDER_TOP = color.new("#323c6e")
local C_BORDER_NRM = color.new("#1c1c37")
local C_BAR_EMPTY = color.new("#232341")
local C_TEXT_MUTED = color.new("#786e96")
local C_WHITE = color.new("#ffffff")
local C_GOLD = color.new("#ffd700")
local C_SILVER = color.new("#C0C0C0")
local C_BRONZE = color.new("#cd7f32")
-- Config stuff
local LEADER_HEIGHT_SCALE = .06
local HEIGHT_SCALE = .03
local PLAYER_HEIGHT = .05
--
local rankColors = { C_GOLD, C_SILVER, C_BRONZE }
local leaderboard = frame:newFrame(x, y, w, h, sx, sy, sw, sh)
leaderboard.color = C_BORDER_NRM
local headernum = leaderboard:newTextLabel("#",0,0,0,0,0,LEADER_HEIGHT_SCALE,1/6,HEIGHT_SCALE)
headernum.align = gui.ALIGN_CENTER
local headerplayer = leaderboard:newTextLabel("PLAYER",0,0,0,0,1/6,LEADER_HEIGHT_SCALE,3/6,HEIGHT_SCALE)
headerplayer.align = gui.ALIGN_LEFT
local headerscore = leaderboard:newTextLabel("SCORE",0,0,0,0,4/6,LEADER_HEIGHT_SCALE,2/6,HEIGHT_SCALE)
headerscore.align = gui.ALIGN_RIGHT
local header = leaderboard:newTextLabel("LEADERBOARD",0,0,0,0,0,0,1,LEADER_HEIGHT_SCALE)
header.borderColor = C_ACCENT
header.textColor = C_ACCENT
header.color = C_BG_HEADER
header.align = gui.ALIGN_CENTER
local BASE_HEIGHT = LEADER_HEIGHT_SCALE + HEIGHT_SCALE
gui.apply({
drawBorder = false,
color = color.new("#0f2a60"),
textColor = C_TEXT_MUTED,
}, headernum, headerplayer, headerscore)
local updateList = {header, headernum, headerplayer, headerscore}
local playerList = {}
local function ScoreResize()
scoreUpdater:newThread(function()
thread.skip(2)
for _,object in pairs(updateList) do
object:fitFont(nil, nil, {scale = 5/6})
object:centerFont()
end
for _,object in pairs(playerList) do
if type(object) == "table" and object.Ref then
for i, player in pairs(object.Ref) do
if player:hasType(gui.TYPE_TEXT) then
player:fitFont(nil, nil, {scale = 5/6})
player:centerFont()
end
end
end
end
end)
end
function scoreboard:AddPlayer(name, score, icon)
local player = {
Name = name,
Score = score,
Icon = icon,
Add = function(self, amt)
self.Score = tostring(tonumber(self.Score) + amt)
table.sort(playerList, function(a, b)
if a.Score == b.Score then
return a.Name < b.Name
end
return tonumber(a.Score) > tonumber(b.Score)
end)
scoreboard:RenderPlayer(playerList)
ScoreResize()
end,
}
table.insert(playerList, player)
scoreboard:RenderPlayer(playerList)
ScoreResize()
return player
end
local colors = {C_GOLD, C_SILVER, C_BRONZE}
local function MapColor(index)
if index <=3 and index > 0 then
return colors[index]
else
return C_WHITE
end
end
function scoreboard:RenderPlayer(list)
for index, player in ipairs(list) do
if player.Ref then
player.Ref[1].text = player.Name or ""
player.Ref[2].text = player.Score or ""
player.Ref[3].text = tostring(index)
player.Ref.Frame:setDualDim(nil,5*index,nil,nil,nil,BASE_HEIGHT + (index-1) * PLAYER_HEIGHT)
player.Ref.Frame.link = player
gui.apply({
visibility = 0,
drawBorder = false,
textColor = MapColor(index)
}, unpack(player.Ref))
if activePlayer == nil then
activePlayer = player.Ref.Frame
end
if player.Ref.Frame == activePlayer then
player.Ref.Frame.borderColor = C_BORDER_NRM
player.Ref.Frame.color = C_ROW_NORM
else
player.Ref.Frame.borderColor = C_BORDER_TOP
player.Ref.Frame.color = C_ROW_TOP
end
else
local playernum, playerName, playerIcon, playerScore, playerLine
local playerFrame = leaderboard:newFrame(5,5*index,-10,0,0,BASE_HEIGHT + (index-1) * PLAYER_HEIGHT,1,PLAYER_HEIGHT)
playerFrame:OnReleased(function(self)
activePlayer = self
scoreboard:RenderPlayer(playerList)
end)
playerFrame:respectHierarchy(false)
playerFrame.borderColor = C_BORDER_TOP
playerFrame.color = C_ROW_TOP
playernum = playerFrame:newTextLabel(index,0,0,0,0,.015,.1,1/8,.8)
playernum.align = gui.ALIGN_CENTER
if player.Icon ~= nil then
playerIcon = playerFrame:newImageLabel(player.Icon,0,0,0,0,.16,.1,.1,.8)
playerIcon.square = "h" -- When working with scales squaring is trickier. (h/w) to switch on width or height
playerName = playerFrame:newTextLabel(player.Name,0,0,0,0,.3,0,2/5,.8)
playerName.align = gui.ALIGN_LEFT
playerLine = playerFrame:newFrame(0,0,0,0,.3,.8,.69,.07)
playerLine.color = C_GOLD
else
playerName = playerFrame:newTextLabel(player.Name,0,0,0,0,.16,0,7/13,.8)
playerName.align = gui.ALIGN_LEFT
playerLine = playerFrame:newFrame(0,0,0,0,.16,.8,7/13 + 2/7,.07)
playerLine.color = C_GOLD
end
playerScore = playerFrame:newTextLabel(player.Score,0,0,0,0,.71,0,2/7,.8)
playerScore.align = gui.ALIGN_CENTER
playerLine.drawBorder = false
gui.apply({
visibility = 0,
drawBorder = false,
textColor = MapColor(index)
},playernum, playerName, playerScore, playerIcon, playerLine)
player.Ref = {playerName, playerScore, playernum, playerIcon, playerLine, Frame = playerFrame}
end
end
end
gui.Events.OnResized(ScoreResize)
return scoreboard
end
require("gui.addons.players")
-- local webp = require("webp")
function love.load()
init()
gui:setAspectSize(1920, 1080)
gui.aspect_ratio = true
local bg = gui:newFrame()
bg:fullFrame()
bg.color = color.new("#242f9b")
local qframe = bg:newFrame(0, 0, 0, 0, .2, .05, .75, .9)
qframe.color = color.new("#060ee9")
local scoreboard = ScoreBoard(bg, 0, 0, 0, 0, .015, .05, .170, .9)
p1 = scoreboard:AddPlayer("Epicknex", "0")
p2 = scoreboard:AddPlayer("Bob", "0")
p3 = scoreboard:AddPlayer("John", "0")
p4 = scoreboard:AddPlayer("Billy", "0")
board.buildBoard(qframe, "anime")
-- gui:newVideoPlayer("test.ogv",0,0,428,240)
-- local img = webp.load("test.webp")
-- gui:newImageLabel(img,0,0,735,1041)
end
function love.update(dt)
gui.update(dt)
multi:uManager(dt)
end
function love.draw()
gui.draw()
end

1
multi Submodule

@ -0,0 +1 @@
Subproject commit 4a52cd5e14abfc409bc82f6427d0cf79f8d2a894

423
server_test/server.lua Normal file
View File

@ -0,0 +1,423 @@
-- server.lua
-- Simple HTTP server in Lua using LuaSocket
-- Install dependency: luarocks install luasocket
local socket = require("socket")
-- ─── Configuration ────────────────────────────────────────────────────────────
local HOST = "127.0.0.1"
local PORT = 8080
-- ─── In-memory data store ─────────────────────────────────────────────────────
local messages = {} -- stores messages sent from the client
-- ─── Helper: parse the request body from raw HTTP request ─────────────────────
local function parse_body(raw)
-- Body follows the blank line after headers
local body = raw:match("\r\n\r\n(.*)")
return body or ""
end
-- ─── Helper: URL-decode a string ──────────────────────────────────────────────
local function url_decode(str)
str = str:gsub("+", " ")
str = str:gsub("%%(%x%x)", function(h)
return string.char(tonumber(h, 16))
end)
return str
end
-- ─── Helper: parse application/x-www-form-urlencoded body ────────────────────
local function parse_form(body)
local params = {}
for key, value in body:gmatch("([^&=]+)=([^&=]*)") do
params[url_decode(key)] = url_decode(value)
end
return params
end
-- ─── Helper: encode a Lua table as a simple JSON object ───────────────────────
local function to_json(tbl)
local parts = {}
for k, v in pairs(tbl) do
local val
if type(v) == "string" then
val = string.format('"%s"', v:gsub('"', '\\"'))
elseif type(v) == "number" then
val = tostring(v)
elseif type(v) == "boolean" then
val = tostring(v)
else
val = '"[unsupported]"'
end
table.insert(parts, string.format('"%s": %s', k, val))
end
return "{ " .. table.concat(parts, ", ") .. " }"
end
-- ─── Helper: encode a list of messages as a JSON array ────────────────────────
local function messages_to_json()
local items = {}
for _, msg in ipairs(messages) do
table.insert(items, to_json(msg))
end
return "[" .. table.concat(items, ", ") .. "]"
end
-- ─── HTTP response builders ────────────────────────────────────────────────────
local function http_response(status, content_type, body)
return string.format(
"HTTP/1.1 %s\r\nContent-Type: %s\r\nContent-Length: %d\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n%s",
status, content_type, #body, body
)
end
local function ok_json(body)
return http_response("200 OK", "application/json", body)
end
local function ok_html(body)
return http_response("200 OK", "text/html", body)
end
local function not_found()
return http_response("404 Not Found", "text/plain", "404 Not Found")
end
-- ─── HTML page served to the browser ─────────────────────────────────────────
local HTML = [[<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Lua Server Demo</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--accent: #58a6ff;
--green: #3fb950;
--text: #e6edf3;
--muted: #8b949e;
--radius: 10px;
}
body {
font-family: 'Courier New', monospace;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
}
header {
text-align: center;
margin-bottom: 36px;
}
header h1 {
font-size: 2rem;
color: var(--accent);
letter-spacing: 2px;
}
header p {
color: var(--muted);
margin-top: 6px;
font-size: 0.85rem;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
width: 100%;
max-width: 640px;
margin-bottom: 24px;
}
.card h2 {
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--muted);
margin-bottom: 16px;
}
input[type="text"] {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
padding: 10px 14px;
font-family: inherit;
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s;
margin-bottom: 10px;
}
input[type="text"]:focus { border-color: var(--accent); }
button {
background: var(--accent);
color: #0d1117;
border: none;
border-radius: var(--radius);
padding: 10px 20px;
font-family: inherit;
font-size: 0.9rem;
font-weight: bold;
cursor: pointer;
transition: opacity 0.2s;
}
button:hover { opacity: 0.85; }
#status {
margin-top: 10px;
font-size: 0.8rem;
color: var(--green);
min-height: 18px;
}
#message-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
#message-list li {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
font-size: 0.88rem;
}
#message-list li span.sender {
color: var(--accent);
font-weight: bold;
margin-right: 8px;
}
#message-list li span.time {
float: right;
color: var(--muted);
font-size: 0.78rem;
}
#server-info {
font-size: 0.82rem;
color: var(--muted);
line-height: 1.8;
}
#server-info b { color: var(--text); }
</style>
</head>
<body>
<header>
<h1>&#x25B6; Lua HTTP Server</h1>
<p>Bidirectional client &harr; server communication demo</p>
</header>
<!-- Send a message -->
<div class="card">
<h2>Send a Message</h2>
<input type="text" id="nameInput" placeholder="Your name" value="Alice" />
<input type="text" id="messageInput" placeholder="Your message" value="Hello from the browser!" />
<button onclick="sendMessage()">Send to Server</button>
<div id="status"></div>
</div>
<!-- Live message feed -->
<div class="card">
<h2>Server Message Log</h2>
<ul id="message-list"><li style="color:var(--muted)">Loading</li></ul>
</div>
<!-- Server info -->
<div class="card">
<h2>Server Info</h2>
<div id="server-info">Fetching</div>
</div>
<script>
const BASE = ""; // same origin
// Send a message to the server
async function sendMessage() {
const name = document.getElementById("nameInput").value.trim();
const message = document.getElementById("messageInput").value.trim();
if (!name || !message) return;
const body = new URLSearchParams({ name, message });
const res = await fetch(BASE + "/send", { method: "POST", body });
const data = await res.json();
document.getElementById("status").textContent =
data.ok ? "✓ Message received by server!" : "✗ Error: " + data.error;
document.getElementById("messageInput").value = "";
fetchMessages();
}
// Fetch all messages from the server
async function fetchMessages() {
const res = await fetch(BASE + "/messages");
const list = await res.json();
const ul = document.getElementById("message-list");
if (list.length === 0) {
ul.innerHTML = '<li style="color:var(--muted)">No messages yet.</li>';
return;
}
ul.innerHTML = list.reverse().map(m => `
<li>
<span class="sender">${esc(m.name)}</span>${esc(m.text)}
<span class="time">${esc(m.time)}</span>
</li>
`).join("");
}
// Fetch server metadata
async function fetchInfo() {
const res = await fetch(BASE + "/info");
const data = await res.json();
document.getElementById("server-info").innerHTML =
`<b>Host:</b> ${esc(data.host)} &nbsp;|&nbsp; <b>Port:</b> ${esc(data.port)}<br>` +
`<b>Uptime:</b> ${esc(data.uptime)}s &nbsp;|&nbsp; <b>Requests served:</b> ${esc(data.requests)}<br>` +
`<b>Lua version:</b> ${esc(data.lua_version)}`;
}
// Simple HTML escape
function esc(s) {
return String(s)
.replace(/&/g,"&amp;").replace(/</g,"&lt;")
.replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
// Poll the server every 3 seconds
fetchMessages();
fetchInfo();
setInterval(fetchMessages, 3000);
setInterval(fetchInfo, 5000);
</script>
</body>
</html>
]]
-- ─── Route handler ────────────────────────────────────────────────────────────
local start_time = os.time()
local request_count = 0
local function handle(raw_request)
request_count = request_count + 1
-- Parse method and path from the request line
local method, path = raw_request:match("^(%u+) (/[^ ]*) HTTP")
method = method or "GET"
path = path or "/"
-- GET / → serve the HTML page
if method == "GET" and path == "/" then
return ok_html(HTML)
-- GET /messages → return all stored messages as JSON
elseif method == "GET" and path == "/messages" then
return ok_json(messages_to_json())
-- GET /info → return server metadata as JSON
elseif method == "GET" and path == "/info" then
local info = {
host = HOST,
port = tostring(PORT),
uptime = tostring(os.time() - start_time),
requests = tostring(request_count),
lua_version = _VERSION,
}
return ok_json(to_json(info))
-- POST /send → accept a message from the client
elseif method == "POST" and path == "/send" then
local body = parse_body(raw_request)
local params = parse_form(body)
local name = params["name"] or "Anonymous"
local text = params["message"] or ""
if text == "" then
return ok_json('{ "ok": false, "error": "empty message" }')
end
-- Store the message
table.insert(messages, {
name = name,
text = text,
time = os.date("%H:%M:%S"),
})
print(string.format("[POST /send] %s: %s", name, text))
return ok_json('{ "ok": true }')
else
return not_found()
end
end
-- ─── Main server loop ─────────────────────────────────────────────────────────
local server = assert(socket.bind(HOST, PORT))
server:settimeout(0) -- non-blocking accept
print(string.format("Lua HTTP server running at http://%s:%d/", HOST, PORT))
print("Press Ctrl-C to stop.\n")
while true do
local client = server:accept()
if client then
client:settimeout(5)
-- Read the full request (stop at blank line for GET, read body for POST)
local raw = {}
local content_length = 0
-- Read headers
while true do
local line, err = client:receive("*l")
if not line or line == "" then break end
table.insert(raw, line)
-- Capture Content-Length so we can read the body
local cl = line:match("^[Cc]ontent%-[Ll]ength:%s*(%d+)")
if cl then content_length = tonumber(cl) end
end
local header_str = table.concat(raw, "\r\n") .. "\r\n\r\n"
-- Read body if present
local body_str = ""
if content_length > 0 then
body_str = client:receive(content_length) or ""
end
local full_request = header_str .. body_str
local response = handle(full_request)
client:send(response)
client:close()
end
socket.sleep(0.01) -- prevent busy-loop
end

10
skills-lock.json Normal file
View File

@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"filesystem": {
"source": "oimiragieo/agent-studio",
"sourceType": "github",
"computedHash": "70b2b09227ca549eac68abeefa5ca2bfba0d158da5faec7be5de875282fc7d28"
}
}
}

56
templates/one-image.lua Executable file
View File

@ -0,0 +1,56 @@
--[[ Constants
* (all lua builtins that don't allow io/executing code)
color (interface)
gui (interface)
multi (interface bound to a processor) no thread module
callback true/false correct/wrong
]]
local label
local imageHolder
local function index(window, q, callback)
frame = window:newFrame(0,0,0,-200,0,.2,1,.8)
frame.visibility = 0
label = window:newTextLabel(" " ..q.title.. " ",0,0,0,0,0,0,1,.2)
label.align = ALIGN_CENTER
label.textColor = color.white
label.color = color.new("#060ce9")
if not q.image or q.image == "" then
error("Missing 'image' field for question!")
end
imageHolder = frame:newImageLabel(q.image)
imageHolder:setAspectSize(imageHolder.imageWidth,imageHolder.imageHeight)
imageHolder:centerX(true)
imageHolder:centerY(true)
imageHolder:setDualDim(0,0,imageHolder.imageWidth,imageHolder.imageHeight)
local correct = window:newTextButton("Correct",0,-200,0,100,0,1,.5)
correct.color = color.new("#52b11b")
local wrong = window:newTextButton("Wrong",0,-200,0,100,.5,1,.5)
wrong.color = color.new("#bd2626")
local skip = window:newTextButton("Skip",0,-100,0,100,.25,1,.5)
skip.color = color.new("#5d5d5d")
window.apply({
fitFont={},
align=window.ALIGN_CENTER,
OnReleased=function(self)
if self.text == "Skip" then
callback()
return
end
callback(self.text == "Correct")
end,
},correct,wrong,skip)
end
local function update(dt) -- time in seconds that has passed since
_,_,w,h = imageHolder.parent:getAbsolutes()
local x,y,w,h = imageHolder:GetSizeAdjustedToAspectRatio(w,h)
imageHolder:setDualDim(w,h,x,y)
label:fitFont()
end
return {
index = index,
update = update
}

44
templates/whatis.lua Executable file
View File

@ -0,0 +1,44 @@
--[[ Constants
* (all lua builtins that don't allow io/executing code)
color (interface)
gui (interface)
multi (interface bound to a processor) no thread module
callback true/false correct/wrong
]]
local label
local function index(window, q, callback)
label = window:newTextLabel(" " ..q.title.. " ")
label.align = ALIGN_CENTER
label:fullFrame()
label.textColor = color.white
label.color = color.new("#060ce9")
local correct = window:newTextButton("Correct",0,-200,0,100,0,1,.5)
correct.color = color.new("#52b11b")
local wrong = window:newTextButton("Wrong",0,-200,0,100,.5,1,.5)
wrong.color = color.new("#bd2626")
local skip = window:newTextButton("Skip",0,-100,0,100,.25,1,.5)
skip.color = color.new("#5d5d5d")
gui.apply({
fitFont={},
align=gui.ALIGN_CENTER,
OnReleased=function(self)
if self.text == "Skip" then
callback()
return
end
callback(self.text == "Correct")
end,
},correct,wrong,skip)
end
local function update(dt) -- time in seconds that has passed since
-- label:centerFont()
label:fitFont()
end
return {
index = index,
update = update
}

BIN
test.ogv Normal file

Binary file not shown.

BIN
test.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

BIN
timesup.mp3 Normal file

Binary file not shown.

108
utils.lua Executable file
View File

@ -0,0 +1,108 @@
local gui = require("gui")
local color = require("gui.core.color")
local transition = require("gui.elements.transitions")
local multi = require("multi"):init()
local function startTimer(opt)
local default = {
duration = 30,
autoText = true,
autoColor = true,
autoCleanup = false,
startColor = color.green,
textColor = color.black,
warnColor = color.yellow,
timeColor = color.red,
finegrained = false,
visibility = 1
}
if type(opt) == "number" then
local d = opt
opt = default
opt.duration = d
elseif type(opt) ~= "table" then
opt = default
elseif type(opt) == "table" then
for i,v in pairs(opt) do
default[i] = v
end
opt = default
end
local timeRemaining = opt.duration or 30
local timer = transition.glide(3.5,1.5,5)
transition.glide:SetFPS(120)
local handle = timer(3.5,1.5,timeRemaining)
local tpie = gui:newFrame():makeArc("pie",-200, 0, 100,1 ,0 ,0 ,1.5*math.pi, 3.5*math.pi, 360)
local tlabel = tpie:newTextLabel("")
tlabel.textColor = opt.textColor
tlabel.align = gui.ALIGN_CENTER
tlabel:fullFrame()
tlabel.visibility = 0
tpie.color = opt.startColor
tpie.visibility = opt.visibility
local tm, num
local func = function(p,t)
if num ~= timeRemaining-math.floor(t) then
num = timeRemaining-math.floor(t)
end
if opt.autoColor then
tpie.color = opt.startColor
if num <= timeRemaining/3 then
tpie.color = opt.timeColor
elseif num <= timeRemaining/2 then
tpie.color = opt.warnColor
end
end
tpie:makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5 * math.pi, p * math.pi, 360)
if opt.autoText then
tlabel.text = num
tlabel:fitFont(nil, nil, {scale=1/2})
tlabel:centerFont()
end
if num == 0 then
thread:newThread("Pie Timer",function()
thread.yield()
if opt.autoText then
tlabel.text = "Time"
tlabel:fitFont(nil, nil, {scale=1/2})
tlabel:centerFont()
end
tpie:makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5 * math.pi, 3.5 * math.pi, 360)
tm.OnStop:Fire(tm)
if opt.autoCleanup then
tm:Cleanup()
end
end)
end
return tm, num, t
end
tm = {
Duration = timeRemaining,
Cleanup = function() handle:Kill() tpie:destroy() tm.Cleanup = function() end end,
SetText = function(str) tlabel.text = str end,
SetColor = function(c) tpie.color = c end,
OnStop = multi:newConnection()
}
if opt.finegrained then
tm.OnTime = func % handle.OnStep
else
tm.OnTime = function(self, sec, t) return math.floor(t) == t, self, sec end / (func % handle.OnStep)
end
return tm
end
return {
startTimer = startTimer
}

602
webp-old.lua Normal file
View File

@ -0,0 +1,602 @@
-- webp.lua — Pure Lua VP8L (lossless WebP) decoder for Love2D (LuaJIT)
-- Supports: VP8L (lossless) only. VP8 (lossy) is not feasible in pure Lua.
-- Usage: local WebP = require("webp"); local img = WebP.load("sprite.webp")
local WebP = {}
local bit = require("bit")
local band = bit.band
local bor = bit.bor
local lshift = bit.lshift
local rshift = bit.rshift
local tobit = bit.tobit
-- ─── Bitstream reader ────────────────────────────────────────────────────────
-- VP8L is LSB-first. We maintain a 32-bit window since LuaJIT bit ops are 32-bit.
local function newBitReader(data)
local r = {
data = data,
pos = 1, -- next byte to load (1-based)
window = 0, -- current bit window (32-bit)
bits = 0, -- valid bits in window
}
function r:fill()
while self.bits <= 24 and self.pos <= #self.data do
local byte = self.data:byte(self.pos)
self.window = bor(self.window, lshift(byte, self.bits))
self.bits = self.bits + 8
self.pos = self.pos + 1
end
end
function r:read(n)
if self.bits < n then self:fill() end
local v = band(self.window, lshift(1, n) - 1)
self.window = rshift(self.window, n)
self.bits = self.bits - n
return v
end
function r:readBool()
return self:read(1) == 1
end
r:fill()
return r
end
-- ─── Huffman trees ───────────────────────────────────────────────────────────
local function buildHuffmanTable(codeLengths)
local n = #codeLengths
local counts = {}
for i = 0, 15 do counts[i] = 0 end
for _, cl in ipairs(codeLengths) do
if cl > 0 then counts[cl] = counts[cl] + 1 end
end
local nextCode = {}
local code = 0
counts[0] = 0
for bits = 1, 15 do
code = lshift(code + counts[bits - 1], 1)
nextCode[bits] = code
end
local htable = {}
for i = 1, n do
local len = codeLengths[i]
if len > 0 then
htable[nextCode[len]] = { sym = i - 1, len = len }
nextCode[len] = nextCode[len] + 1
end
end
return htable
end
local function decodeHuffman(br, htable)
local code = 0
for len = 1, 15 do
code = bor(lshift(code, 1), br:read(1))
local entry = htable[code]
if entry and entry.len == len then
return entry.sym
end
end
error("Invalid Huffman code")
end
-- ─── Code length decoding ────────────────────────────────────────────────────
local CODE_LENGTH_ORDER = {17,18,0,1,2,3,4,5,16,6,7,8,9,10,11,12,13,14,15}
local function readCodeLengths(br, n)
local numCLCodes = br:read(4) + 4
local clLengths = {}
for i = 1, 19 do clLengths[i] = 0 end
for i = 1, numCLCodes do
clLengths[CODE_LENGTH_ORDER[i] + 1] = br:read(3)
end
local clTable = buildHuffmanTable(clLengths)
local lengths = {}
local prev = 8
while #lengths < n do
local sym = decodeHuffman(br, clTable)
if sym <= 15 then
lengths[#lengths + 1] = sym
if sym ~= 0 then prev = sym end
elseif sym == 16 then
local rep = br:read(2) + 3
for _ = 1, rep do lengths[#lengths + 1] = prev end
elseif sym == 17 then
local rep = br:read(3) + 3
for _ = 1, rep do lengths[#lengths + 1] = 0 end
elseif sym == 18 then
local rep = br:read(7) + 11
for _ = 1, rep do lengths[#lengths + 1] = 0 end
end
end
return lengths
end
-- ─── Prefix code reading ─────────────────────────────────────────────────────
local function readPrefixCode(br, alphabetSize)
local simpleCode = br:read(1)
if simpleCode == 1 then
local numSyms = br:read(1) + 1
local firstSym = br:read(1)
local sym1 = br:read(firstSym == 1 and 8 or 1)
local lengths = {}
for i = 1, alphabetSize do lengths[i] = 0 end
lengths[sym1 + 1] = 1
if numSyms == 2 then
local sym2 = br:read(8)
lengths[sym2 + 1] = 1
end
return buildHuffmanTable(lengths)
else
local lengths = readCodeLengths(br, alphabetSize)
return buildHuffmanTable(lengths)
end
end
-- ─── Color cache ─────────────────────────────────────────────────────────────
local function newColorCache(bits)
local size = lshift(1, bits)
local cache = {}
for i = 0, size - 1 do cache[i] = 0 end
return {
size = size,
data = cache,
insert = function(self, color)
-- hash: (0x1e35a7bd * color) >> (32 - bits), masked to cache size
local hash = tobit(0x1e35a7bd * color)
local idx = band(rshift(hash, 32 - bits), size - 1)
self.data[idx] = color
end,
lookup = function(self, idx)
return self.data[idx]
end,
}
end
-- ─── Transform type constants ────────────────────────────────────────────────
local TRANSFORM_PREDICTOR = 0
local TRANSFORM_COLOR = 1
local TRANSFORM_SUBTRACT_GREEN = 2
local TRANSFORM_COLOR_INDEXING = 3
-- ─── Prefix length/distance tables ──────────────────────────────────────────
local PREFIX_EXTRA_BITS = {
0,0,0,0, 1,1,2,2, 3,3,4,4, 5,5,6,6, 7,7,8,8, 9,9,10,10, 11,11,12,12, 13,13
}
local PREFIX_OFFSET = {
0,1,2,3, 4,6,8,12, 16,24,32,48, 64,96,128,192, 256,384,512,768,
1024,1536,2048,3072, 4096,6144,8192,12288, 16384,24576
}
local function prefixToValue(br, code)
if code < 4 then return code end
local extra = PREFIX_EXTRA_BITS[code + 1] or 0
local offset = PREFIX_OFFSET[code + 1] or 0
return offset + br:read(extra)
end
-- ─── VP8L distance offset table (120 entries) ────────────────────────────────
local DIST_MAP = {
{0,1},{1,0},{1,1},{-1,1},{0,2},{2,0},{1,2},{-1,2},
{2,1},{-2,1},{2,2},{-2,2},{0,3},{3,0},{1,3},{-1,3},
{3,1},{-3,1},{2,3},{-2,3},{3,2},{-3,2},{0,4},{4,0},
{1,4},{-1,4},{4,1},{-4,1},{3,3},{-3,3},{2,4},{-2,4},
{4,2},{-4,2},{0,5},{3,4},{-3,4},{4,3},{-4,3},{5,0},
{1,5},{-1,5},{5,1},{-5,1},{2,5},{-2,5},{5,2},{-5,2},
{4,4},{-4,4},{3,5},{-3,5},{5,3},{-5,3},{0,6},{6,0},
{1,6},{-1,6},{6,1},{-6,1},{2,6},{-2,6},{6,2},{-6,2},
{4,5},{-4,5},{5,4},{-5,4},{3,6},{-3,6},{6,3},{-6,3},
{0,7},{7,0},{1,7},{-1,7},{5,5},{-5,5},{7,1},{-7,1},
{4,6},{-4,6},{6,4},{-6,4},{2,7},{-2,7},{7,2},{-7,2},
{3,7},{-3,7},{7,3},{-7,3},{5,6},{-5,6},{6,5},{-6,5},
{8,0},{4,7},{-4,7},{7,4},{-7,4},{8,1},{8,2},{6,6},
{-6,6},{8,3},{5,7},{-5,7},{7,5},{-7,5},{8,4},{6,7},
{-6,7},{7,6},{-7,6},{8,5},{8,6},{7,7},{-7,7},{8,7},
}
-- ─── Main VP8L decode (recursive for transform sub-images) ───────────────────
local function decodeVP8L(br, width, height)
-- color cache
local hasCCache = br:readBool()
local ccache = nil
local ccacheBits = 0
if hasCCache then
ccacheBits = br:read(4)
ccache = newColorCache(ccacheBits)
end
-- collect transforms (applied in reverse after decode)
local transforms = {}
while br:readBool() do
local ttype = br:read(2)
local t = { ttype = ttype }
if ttype == TRANSFORM_PREDICTOR or ttype == TRANSFORM_COLOR then
t.sizeBits = br:read(3) + 2
local tw = math.floor((width + lshift(1, t.sizeBits) - 1) / lshift(1, t.sizeBits))
local th = math.floor((height + lshift(1, t.sizeBits) - 1) / lshift(1, t.sizeBits))
t.data = decodeVP8L(br, tw, th)
elseif ttype == TRANSFORM_COLOR_INDEXING then
t.numColors = br:read(8) + 1
t.colors = decodeVP8L(br, t.numColors, 1)
end
-- SUBTRACT_GREEN has no extra data
transforms[#transforms + 1] = t
end
-- entropy/meta-Huffman image
local groupBits = 0
local groupImage = nil
local numGroups = 1
if br:readBool() then
groupBits = br:read(3) + 2
local gw = math.floor((width + lshift(1, groupBits) - 1) / lshift(1, groupBits))
local gh = math.floor((height + lshift(1, groupBits) - 1) / lshift(1, groupBits))
groupImage = decodeVP8L(br, gw, gh)
local maxG = 0
for _, v in ipairs(groupImage) do
local g = band(rshift(v, 8), 0xffff)
if g > maxG then maxG = g end
end
numGroups = maxG + 1
end
-- alphabet sizes
local ccSize = hasCCache and lshift(1, ccacheBits) or 0
local alphabetG = 256 + 24 + ccSize
-- read Huffman tables for each group (G, R, B, A + distance)
local huffGroups = {}
for g = 1, numGroups do
huffGroups[g] = {
G = readPrefixCode(br, alphabetG),
R = readPrefixCode(br, 256),
B = readPrefixCode(br, 256),
A = readPrefixCode(br, 256),
dist = readPrefixCode(br, 40),
}
end
-- decode pixels
local pixels = {}
local numPixels = width * height
local px, py = 0, 0
local function getGroup()
if not groupImage then return huffGroups[1] end
local gx = rshift(px, groupBits)
local gy = rshift(py, groupBits)
local gw = math.floor((width + lshift(1, groupBits) - 1) / lshift(1, groupBits))
local idx = gy * gw + gx + 1
local g = band(rshift(groupImage[idx] or 0, 8), 0xffff)
return huffGroups[g + 1] or huffGroups[1]
end
while #pixels < numPixels do
local hg = getGroup()
local code = decodeHuffman(br, hg.G)
if code < 256 then
-- literal ARGB (green first, then R, B, A)
local g = code
local r = decodeHuffman(br, hg.R)
local b = decodeHuffman(br, hg.B)
local a = decodeHuffman(br, hg.A)
local color = bor(bor(bor(lshift(a, 24), lshift(r, 16)), lshift(g, 8)), b)
pixels[#pixels + 1] = color
if ccache then ccache:insert(color) end
elseif code < 256 + 24 then
-- LZ77 back-reference
local lenCode = code - 256
local length = prefixToValue(br, lenCode) + 1
local distCode = decodeHuffman(br, hg.dist)
local dist = prefixToValue(br, distCode) + 1
-- remap small distances through VP8L spatial table
if dist <= 120 then
local d = DIST_MAP[dist]
local srcX = px - d[1]
local srcY = py - d[2]
dist = py * width + px - (srcY * width + srcX)
end
local src = #pixels - dist + 1
for i = 0, length - 1 do
local c = pixels[src + i] or 0
pixels[#pixels + 1] = c
if ccache then ccache:insert(c) end
end
else
-- color cache reference
local cacheIdx = code - 256 - 24
pixels[#pixels + 1] = ccache:lookup(cacheIdx)
end
px = px + 1
if px >= width then px = 0; py = py + 1 end
end
-- ─── Apply transforms in reverse order ───────────────────────────────────
for i = #transforms, 1, -1 do
local t = transforms[i]
-- SUBTRACT_GREEN: R += G, B += G (mod 256)
if t.ttype == TRANSFORM_SUBTRACT_GREEN then
for idx = 1, #pixels do
local c = pixels[idx]
local a = band(rshift(c, 24), 0xff)
local r = band(rshift(c, 16), 0xff)
local g = band(rshift(c, 8), 0xff)
local b = band(c, 0xff)
r = band(r + g, 0xff)
b = band(b + g, 0xff)
pixels[idx] = bor(bor(bor(lshift(a, 24), lshift(r, 16)), lshift(g, 8)), b)
end
-- COLOR_INDEXING: replace each pixel's green channel with palette entry
elseif t.ttype == TRANSFORM_COLOR_INDEXING then
local bpp = 8
if t.numColors <= 2 then bpp = 1
elseif t.numColors <= 4 then bpp = 2
elseif t.numColors <= 16 then bpp = 4 end
local newPixels = {}
if bpp == 8 then
for _, c in ipairs(pixels) do
local idx = band(rshift(c, 8), 0xff)
newPixels[#newPixels + 1] = t.colors[idx + 1] or 0
end
else
local pxPerByte = 8 / bpp
local mask = lshift(1, bpp) - 1
for _, c in ipairs(pixels) do
local packed = band(rshift(c, 8), 0xff)
for p = 0, pxPerByte - 1 do
if #newPixels < width * height then
local idx = band(rshift(packed, p * bpp), mask)
newPixels[#newPixels + 1] = t.colors[idx + 1] or 0
end
end
end
end
pixels = newPixels
-- PREDICTOR: undo per-block spatial prediction
elseif t.ttype == TRANSFORM_PREDICTOR then
local sb = t.sizeBits
local tw = math.floor((width + lshift(1, sb) - 1) / lshift(1, sb))
local function getpx(x, y)
if x < 0 then x = 0 end
if y < 0 then return tobit(0xff000000) end
return pixels[y * width + x + 1] or 0
end
local function addARGB(a, b)
local aa = band(rshift(a, 24), 0xff)
local ar = band(rshift(a, 16), 0xff)
local ag = band(rshift(a, 8), 0xff)
local ab = band(a, 0xff)
local ba = band(rshift(b, 24), 0xff)
local br_ = band(rshift(b, 16), 0xff)
local bg = band(rshift(b, 8), 0xff)
local bb = band(b, 0xff)
return bor(bor(bor(
lshift(band(aa + ba, 0xff), 24),
lshift(band(ar + br_, 0xff), 16)),
lshift(band(ag + bg, 0xff), 8)),
band(ab + bb, 0xff))
end
local function avgARGB(a, b)
local aa = band(rshift(a, 24), 0xff)
local ar = band(rshift(a, 16), 0xff)
local ag = band(rshift(a, 8), 0xff)
local ab = band(a, 0xff)
local ba = band(rshift(b, 24), 0xff)
local br_ = band(rshift(b, 16), 0xff)
local bg = band(rshift(b, 8), 0xff)
local bb = band(b, 0xff)
return bor(bor(bor(
lshift(rshift(aa + ba, 1), 24),
lshift(rshift(ar + br_, 1), 16)),
lshift(rshift(ag + bg, 1), 8)),
rshift(ab + bb, 1))
end
local function clampByte(v)
return math.max(0, math.min(255, v))
end
local function clampAddARGB(a, b)
local aa = band(rshift(a, 24), 0xff)
local ar = band(rshift(a, 16), 0xff)
local ag = band(rshift(a, 8), 0xff)
local ab = band(a, 0xff)
local ba = band(rshift(b, 24), 0xff)
local br_ = band(rshift(b, 16), 0xff)
local bg = band(rshift(b, 8), 0xff)
local bb = band(b, 0xff)
return bor(bor(bor(
lshift(clampByte(aa + ba), 24),
lshift(clampByte(ar + br_), 16)),
lshift(clampByte(ag + bg), 8)),
clampByte(ab + bb))
end
local function subARGB(a, b)
local aa = band(rshift(a, 24), 0xff)
local ar = band(rshift(a, 16), 0xff)
local ag = band(rshift(a, 8), 0xff)
local ab = band(a, 0xff)
local ba = band(rshift(b, 24), 0xff)
local br_ = band(rshift(b, 16), 0xff)
local bg = band(rshift(b, 8), 0xff)
local bb = band(b, 0xff)
return bor(bor(bor(
lshift(band(aa - ba, 0xff), 24),
lshift(band(ar - br_, 0xff), 16)),
lshift(band(ag - bg, 0xff), 8)),
band(ab - bb, 0xff))
end
local function halfSubARGB(a, b)
local aa = band(rshift(a, 24), 0xff)
local ar = band(rshift(a, 16), 0xff)
local ag = band(rshift(a, 8), 0xff)
local ab = band(a, 0xff)
local ba = band(rshift(b, 24), 0xff)
local br_ = band(rshift(b, 16), 0xff)
local bg = band(rshift(b, 8), 0xff)
local bb = band(b, 0xff)
return bor(bor(bor(
lshift(rshift(aa - ba, 1), 24),
lshift(rshift(ar - br_, 1), 16)),
lshift(rshift(ag - bg, 1), 8)),
rshift(ab - bb, 1))
end
local function selectARGB(l, tp, tl)
local function absdiff(x, y, s)
return math.abs(band(rshift(x, s), 0xff) - band(rshift(y, s), 0xff))
end
local function dist(x, y)
return absdiff(x,y,24) + absdiff(x,y,16) + absdiff(x,y,8) + absdiff(x,y,0)
end
return dist(l, tl) <= dist(tp, tl) and l or tp
end
for iy = 0, height - 1 do
for ix = 0, width - 1 do
if not (ix == 0 and iy == 0) then
local pidx = iy * width + ix + 1
local L = getpx(ix - 1, iy)
local T = getpx(ix, iy - 1)
local TL = getpx(ix - 1, iy - 1)
local TR = getpx(ix + 1, iy - 1)
local tx = rshift(ix, sb)
local ty = rshift(iy, sb)
local mode = band(t.data[ty * tw + tx + 1] or 0, 0xff)
local pred
if mode == 0 then pred = tobit(0xff000000)
elseif mode == 1 then pred = L
elseif mode == 2 then pred = T
elseif mode == 3 then pred = TR
elseif mode == 4 then pred = TL
elseif mode == 5 then pred = avgARGB(avgARGB(L, TR), T)
elseif mode == 6 then pred = avgARGB(L, TL)
elseif mode == 7 then pred = avgARGB(L, T)
elseif mode == 8 then pred = avgARGB(TL, T)
elseif mode == 9 then pred = avgARGB(T, TR)
elseif mode == 10 then pred = avgARGB(avgARGB(L, TL), avgARGB(T, TR))
elseif mode == 11 then pred = selectARGB(L, T, TL)
elseif mode == 12 then pred = clampAddARGB(L, subARGB(T, TL))
elseif mode == 13 then
local avg = avgARGB(L, T)
pred = clampAddARGB(avg, halfSubARGB(avg, TL))
else pred = L end
pixels[pidx] = addARGB(pixels[pidx], pred)
end
end
end
-- COLOR: undo green/red channel correlations
elseif t.ttype == TRANSFORM_COLOR then
local sb = t.sizeBits
local tw = math.floor((width + lshift(1, sb) - 1) / lshift(1, sb))
for iy = 0, height - 1 do
for ix = 0, width - 1 do
local pidx = iy * width + ix + 1
local c = pixels[pidx] or 0
local tx = rshift(ix, sb)
local ty = rshift(iy, sb)
local m = t.data[ty * tw + tx + 1] or 0
-- unpack signed bytes from transform pixel (stored as ARGB)
local g2r = band(rshift(m, 16), 0xff)
local r2b = band(rshift(m, 8), 0xff)
local g2b = band(m, 0xff)
if g2r >= 128 then g2r = g2r - 256 end
if r2b >= 128 then r2b = r2b - 256 end
if g2b >= 128 then g2b = g2b - 256 end
local a = band(rshift(c, 24), 0xff)
local r = band(rshift(c, 16), 0xff)
local g = band(rshift(c, 8), 0xff)
local b = band(c, 0xff)
r = band(r + math.floor(g2r * g / 256), 0xff)
b = band(b + math.floor(g2b * g / 256) + math.floor(r2b * r / 256), 0xff)
pixels[pidx] = bor(bor(bor(lshift(a, 24), lshift(r, 16)), lshift(g, 8)), b)
end
end
end
end
return pixels
end
-- ─── Public API ──────────────────────────────────────────────────────────────
function WebP.decode(data)
assert(data:sub(1, 4) == "RIFF", "Not a RIFF file")
assert(data:sub(9, 12) == "WEBP", "Not a WEBP file")
local fourCC = data:sub(13, 16)
assert(fourCC == "VP8L", "Only lossless VP8L WebP is supported (got: " .. fourCC .. ")")
-- byte 21 = VP8L signature (0x2f), bitstream starts at byte 22
assert(data:byte(21) == 0x2f, "Invalid VP8L signature byte")
local br = newBitReader(data:sub(22))
local width = br:read(14) + 1
local height = br:read(14) + 1
br:readBool() -- alpha hint flag, unused
local version = br:read(3)
assert(version == 0, "Unsupported VP8L version: " .. version)
local pixels = decodeVP8L(br, width, height)
-- VP8L stores ARGB; Love2D mapPixel expects normalised RGBA floats
local imageData = love.image.newImageData(width, height, "rgba8")
imageData:mapPixel(function(x, y)
local c = pixels[y * width + x + 1] or 0
local a = band(rshift(c, 24), 0xff)
local r = band(rshift(c, 16), 0xff)
local g = band(rshift(c, 8), 0xff)
local b = band(c, 0xff)
return r / 255, g / 255, b / 255, a / 255
end)
return love.graphics.newImage(imageData)
end
function WebP.load(path)
local data = love.filesystem.read(path)
assert(data, "Could not read file: " .. path)
return WebP.decode(data)
end
return WebP

1626
webp.lua Normal file

File diff suppressed because it is too large Load Diff

776
yaml.lua Executable file
View 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,
}