Initial commit
0
.gitignore
vendored
Normal file
3
.gitmodules
vendored
Executable file
@ -0,0 +1,3 @@
|
||||
[submodule "multi"]
|
||||
path = multi
|
||||
url = https://github.com/rayaman/multi.git
|
||||
74
CLAUDE.md
Normal 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)
|
||||
BIN
anime/assets/shounen_name_characters.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
25
anime/index.yaml
Executable 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
@ -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
|
||||
70
anime/templates/imageword.lua
Normal 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
|
After Width: | Height: | Size: 961 KiB |
BIN
assets/anime-gif-boobs-funny-2710211137.gif
Executable file
|
After Width: | Height: | Size: 962 KiB |
BIN
assets/anime-girls-boobs-minimalism-1455325-1259150827.jpg
Executable file
|
After Width: | Height: | Size: 158 KiB |
169
assets/convert.lua
Executable 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)
|
||||
BIN
assets/final-jeopardy-1639404433135-3347818171.jpeg
Executable file
|
After Width: | Height: | Size: 179 KiB |
400
assets/json.lua
Executable 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
|
After Width: | Height: | Size: 21 KiB |
176
board.lua
Normal 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
@ -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
355
fmt.lua
Normal 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
40
gui/README.md
Executable 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
@ -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
43
gui/addons/players.lua
Normal 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
@ -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
@ -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
|
After Width: | Height: | Size: 8.3 KiB |
BIN
gui/assets/play.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
1
gui/changes.md
Executable file
@ -0,0 +1 @@
|
||||
#
|
||||
26
gui/core/canvas.lua
Executable 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
91
gui/core/simulate.lua
Executable 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
@ -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
|
||||
866
gui/docs/gui-library-docs.md
Normal 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 (0–1) 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 (0–1) |
|
||||
| `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 (0–1) |
|
||||
| `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 (0–1) |
|
||||
|
||||
### 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 (0–1) |
|
||||
| `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.
|
||||
186
gui/docs/yaml-based-elements.yaml
Normal file
@ -0,0 +1,186 @@
|
||||
# ── Full GUI YAML reference ─────────────────────────────────────────
|
||||
|
||||
# Every element shares these fields:
|
||||
type: frame # frame | virtual-frame | visual-frame |
|
||||
# label | button | textbox |
|
||||
# image | image-button | video
|
||||
|
||||
# ── Position & size (dual-dimension system) ─────────────────────────
|
||||
# Offset (pixels):
|
||||
x: 10
|
||||
y: 10
|
||||
w: 200
|
||||
h: 50
|
||||
# Or as lists:
|
||||
pos: [10, 10]
|
||||
size: [200, 50]
|
||||
|
||||
# Scale (0.0–1.0 of parent):
|
||||
sx: 0.0 # scale pos x
|
||||
sy: 0.0 # scale pos y
|
||||
sw: 0.5 # scale size x — 50% of parent width
|
||||
sh: 1.0 # scale size y — 100% of parent height
|
||||
# Or as lists:
|
||||
scale-pos: [0.0, 0.0]
|
||||
scale-size: [0.5, 1.0]
|
||||
|
||||
# Shorthand for "fill parent completely":
|
||||
full-frame: true
|
||||
|
||||
# ── Appearance ───────────────────────────────────────────────────────
|
||||
color: "#3a7bd5" # hex string
|
||||
color: [58, 123, 213] # RGB 0-255
|
||||
color: [0.23, 0.48, 0.84] # RGB 0-1
|
||||
border-color: "#000000"
|
||||
draw-border: true
|
||||
visible: true
|
||||
active: true
|
||||
visibility: 1.0 # 0.0–1.0 alpha for the background rect
|
||||
rotation: 45 # degrees
|
||||
|
||||
# ── Form factor ──────────────────────────────────────────────────────
|
||||
form: rectangle # default — no extra fields needed
|
||||
form: circle
|
||||
radius: 40 # optional; derived from w if omitted
|
||||
segments: 32
|
||||
|
||||
form: arc
|
||||
radius: 60
|
||||
arc-type: open # open | closed | pie
|
||||
angle-start: 0
|
||||
angle-end: 3.14159
|
||||
segments: 32
|
||||
|
||||
# ── Roundness ────────────────────────────────────────────────────────
|
||||
roundness: 8 # uniform rx/ry
|
||||
roundness: [8, 8, 30] # rx, ry, segments
|
||||
roundness: top # "top" or "bottom" special mode
|
||||
roundness:
|
||||
side: top # full table form
|
||||
|
||||
# ── Tags ─────────────────────────────────────────────────────────────
|
||||
tag: "my-element" # single fast-lookup tag (gui:tag)
|
||||
tags: # multi-tag (gui:setTag)
|
||||
- draggable-panel
|
||||
- visual
|
||||
|
||||
# ── Stack order ──────────────────────────────────────────────────────
|
||||
stack: top # bring to front
|
||||
stack: bottom # send to back
|
||||
|
||||
# ── Centering ────────────────────────────────────────────────────────
|
||||
center-x: true # horizontally center within parent
|
||||
center-y: true # vertically center within parent
|
||||
|
||||
# ── Dragging ─────────────────────────────────────────────────────────
|
||||
draggable: 1 # mouse button (1=primary, 2=secondary, 3=middle)
|
||||
# false/omit to disable
|
||||
|
||||
# ── Hierarchy & clipping ─────────────────────────────────────────────
|
||||
respect-hierarchy: true # blocks presses when covered by sibling
|
||||
clip-descendants: true # scissor-clips all children to this rect
|
||||
|
||||
# ── Square locking ───────────────────────────────────────────────────
|
||||
square: w # force height = width
|
||||
square: h # force width = height
|
||||
|
||||
# ── Text element fields (label, button, textbox) ─────────────────────
|
||||
text: "Hello, world!"
|
||||
align: left # left | center | right
|
||||
text-color: "#ffffff"
|
||||
text-visibility: 1.0
|
||||
text-scale: [1.0, 1.0] # [scaleX, scaleY]
|
||||
text-offset: [0, 0] # [offsetX, offsetY]
|
||||
text-shear: [0, 0] # [shearX, shearY]
|
||||
|
||||
font: 16 # size, uses default font
|
||||
font: "fonts/Roboto-Regular.ttf" # path, uses font-size below
|
||||
font-size: 18
|
||||
font:
|
||||
file: "fonts/Roboto-Regular.ttf"
|
||||
size: 18
|
||||
|
||||
fit-font: true # auto-fit font to element bounds
|
||||
fit-font:
|
||||
min: 8
|
||||
max: 200
|
||||
scale: 0.95 # shrink slightly from computed best
|
||||
|
||||
center-font: true # vertically center glyphs in box
|
||||
center-font: 10 # with y_offset
|
||||
|
||||
# ── Image element fields (image, image-button) ───────────────────────
|
||||
source: "assets/logo.png"
|
||||
tile: [0, 0, 64, 64] # sub-quad [x, y, w, h]
|
||||
scale-x: 1.0
|
||||
scale-y: 1.0
|
||||
flip: horizontal # horizontal | vertical | both
|
||||
image-color: "#ffffff"
|
||||
image-visibility: 1.0
|
||||
|
||||
# Gradient (replaces solid image with a generated gradient image):
|
||||
gradient:
|
||||
direction: vertical # vertical | horizontal
|
||||
colors:
|
||||
- [255, 80, 80, 255]
|
||||
- [80, 80, 255, 255]
|
||||
|
||||
# ── Video element fields ──────────────────────────────────────────────
|
||||
source: "assets/intro.ogv"
|
||||
volume: 0.8
|
||||
autoplay: true
|
||||
video-color: "#ffffff"
|
||||
video-visibility: 1.0
|
||||
|
||||
# ── Events ───────────────────────────────────────────────────────────
|
||||
# Value can be a global function name (string) or inline Lua source.
|
||||
|
||||
on-pressed: "myPressHandler"
|
||||
on-released: "myReleaseHandler"
|
||||
on-released-outer: "myOuterRelease"
|
||||
on-pressed-outer: "myOuterPress"
|
||||
on-enter: "onHoverEnter"
|
||||
on-exit: "onHoverExit"
|
||||
on-moved: "onMouseMoved"
|
||||
on-drag-start: "onDragStart"
|
||||
on-dragging: "onDragging"
|
||||
on-drag-end: "onDragEnd"
|
||||
on-wheel: "onWheel"
|
||||
on-size-changed: "onResized"
|
||||
on-position-changed: "onMoved"
|
||||
on-destroy: "onDestroy"
|
||||
on-load: "onLoaded"
|
||||
on-return: "onSubmit" # textbox only
|
||||
on-update: "onUpdate" # called every frame
|
||||
|
||||
# Inline Lua (multi-line string):
|
||||
on-pressed: |
|
||||
print("pressed!", self.text)
|
||||
|
||||
# Per-element hotkeys:
|
||||
hotkeys:
|
||||
- keys: [lctrl, s]
|
||||
action: "saveDocument"
|
||||
- keys: [escape]
|
||||
action: "closeDialog"
|
||||
|
||||
# ── Children ─────────────────────────────────────────────────────────
|
||||
children:
|
||||
- type: label
|
||||
text: "I am a child"
|
||||
x: 10
|
||||
y: 10
|
||||
w: 180
|
||||
h: 30
|
||||
color: "#2a2a2a"
|
||||
text-color: "#ffffff"
|
||||
align: center
|
||||
|
||||
- type: button
|
||||
text: "Click me"
|
||||
x: 10
|
||||
y: 50
|
||||
w: 100
|
||||
h: 36
|
||||
on-pressed: "handleClick"
|
||||
children: [] # buttons can also have children
|
||||
69
gui/elements/init.lua
Executable 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
@ -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
430
gui/yaml/builder.lua
Normal file
@ -0,0 +1,430 @@
|
||||
-- gui_yaml.lua
|
||||
-- Parses a YAML-like table (pre-parsed by a YAML lib) into GUI elements.
|
||||
-- Usage: local yaml = require("tinyyaml") (or lyaml, etc.)
|
||||
-- local def = yaml.parse(yaml_string)
|
||||
-- local root = gui_yaml.build(gui, def)
|
||||
|
||||
local gui_yaml = {}
|
||||
|
||||
-- ─────────────────────────────────────────────
|
||||
-- Helpers
|
||||
-- ─────────────────────────────────────────────
|
||||
|
||||
local function parseColor(v)
|
||||
if type(v) == "string" then
|
||||
return require("gui.core.color").new(v)
|
||||
elseif type(v) == "table" then
|
||||
-- {r, g, b} or {r, g, b, a} — values 0-255 or 0-1
|
||||
local r, g, b, a = v[1], v[2], v[3], v[4] or 255
|
||||
-- normalise if in 0-255 range
|
||||
if r > 1 or g > 1 or b > 1 then
|
||||
r, g, b = r/255, g/255, b/255
|
||||
if a > 1 then a = a/255 end
|
||||
end
|
||||
return {r, g, b, a}
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function parseDualDim(def)
|
||||
--[[
|
||||
YAML formats accepted:
|
||||
pos: [x, y] offset
|
||||
size: [w, h] offset
|
||||
scale-pos: [sx, sy] scale (0-1)
|
||||
scale-size: [sw, sh] scale (0-1)
|
||||
|
||||
Or shorthand flat:
|
||||
x, y, w, h, sx, sy, sw, sh
|
||||
]]
|
||||
local px = def.x or (def.pos and def.pos[1]) or 0
|
||||
local py = def.y or (def.pos and def.pos[2]) or 0
|
||||
local pw = def.w or def.width or (def.size and def.size[1]) or 0
|
||||
local ph = def.h or def.height or (def.size and def.size[2]) or 0
|
||||
local sx = def.sx or (def["scale-pos"] and def["scale-pos"][1]) or 0
|
||||
local sy = def.sy or (def["scale-pos"] and def["scale-pos"][2]) or 0
|
||||
local sw = def.sw or (def["scale-size"] and def["scale-size"][1]) or 0
|
||||
local sh = def.sh or (def["scale-size"] and def["scale-size"][2]) or 0
|
||||
return px, py, pw, ph, sx, sy, sw, sh
|
||||
end
|
||||
|
||||
local function applyShared(parent, obj, def)
|
||||
-- Color / border
|
||||
if def.color then obj.color = parseColor(def.color) end
|
||||
if def["border-color"] then obj.borderColor = parseColor(def["border-color"]) end
|
||||
if def["draw-border"] ~= nil then obj.drawBorder = def["draw-border"] end
|
||||
|
||||
-- Visibility
|
||||
if def.visible ~= nil then obj.visible = def.visible end
|
||||
if def.active ~= nil then obj.active = def.active end
|
||||
if def.visibility ~= nil then obj.visibility = def.visibility end
|
||||
|
||||
-- Rotation
|
||||
if def.rotation then obj.rotation = def.rotation end
|
||||
|
||||
-- Tag
|
||||
if def.tag then obj:tag(def.tag) end
|
||||
|
||||
-- Tags (multi)
|
||||
if def.tags then
|
||||
for _, t in ipairs(def.tags) do obj:setTag(t) end
|
||||
end
|
||||
|
||||
-- Form factor
|
||||
if def.form then
|
||||
local f = def.form
|
||||
if f == "circle" then
|
||||
local x, y, w, h, sx, sy, sw = parseDualDim(def)
|
||||
local r = def.radius or (w / 2)
|
||||
obj:makeCircle(x, y, r, sx, sy, sw, def.segments)
|
||||
elseif f == "arc" then
|
||||
local x, y, w, h, sx, sy, sw = parseDualDim(def)
|
||||
local r = def.radius or (w / 2)
|
||||
obj:makeArc(
|
||||
def["arc-type"] or "open",
|
||||
x, y, r, sx, sy, sw,
|
||||
def["angle-start"] or 0,
|
||||
def["angle-end"] or math.pi * 2,
|
||||
def.segments
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
-- Roundness
|
||||
if def.roundness then
|
||||
local r = def.roundness
|
||||
if type(r) == "table" then
|
||||
obj:setRoundness(r[1], r[2], r[3], r.side)
|
||||
elseif type(r) == "string" then
|
||||
-- "top" | "bottom" shorthand
|
||||
obj:setRoundness(5, 5, 30, r)
|
||||
else
|
||||
obj:setRoundness(r, r, 30)
|
||||
end
|
||||
end
|
||||
|
||||
-- Centering
|
||||
if def["center-x"] then obj:centerX(def["center-x"]) end
|
||||
if def["center-y"] then obj:centerY(def["center-y"]) end
|
||||
|
||||
-- Full frame shorthand
|
||||
if def["full-frame"] then obj:fullFrame() end
|
||||
|
||||
-- Square lock
|
||||
if def.square then obj.square = def.square end
|
||||
|
||||
-- Dragging
|
||||
if def.draggable then
|
||||
obj:enableDragging(
|
||||
type(def.draggable) == "number" and def.draggable or 1
|
||||
)
|
||||
end
|
||||
|
||||
-- Hierarchy
|
||||
if def["respect-hierarchy"] ~= nil then
|
||||
obj:respectHierarchy(def["respect-hierarchy"])
|
||||
end
|
||||
|
||||
-- Clip descendants
|
||||
if def["clip-descendants"] ~= nil then
|
||||
obj.clipDescendants = def["clip-descendants"]
|
||||
end
|
||||
|
||||
-- Effects (function reference by name — looked up via _G or a registry)
|
||||
if def.effect then
|
||||
local fn = type(def.effect) == "function"
|
||||
and def.effect
|
||||
or _G[def.effect]
|
||||
if fn then obj.effect = fn end
|
||||
end
|
||||
|
||||
-- Shader (name → looked up in _G)
|
||||
if def.shader then
|
||||
obj.shader = type(def.shader) == "userdata"
|
||||
and def.shader
|
||||
or _G[def.shader]
|
||||
end
|
||||
|
||||
-- Position on stack
|
||||
if def.stack then
|
||||
if def.stack == "top" then obj:topStack() end
|
||||
if def.stack == "bottom" then obj:bottomStack() end
|
||||
end
|
||||
end
|
||||
|
||||
local function applyEvents(obj, def, env)
|
||||
--[[
|
||||
Events in YAML can be:
|
||||
on-pressed: "myFunction" -- looks up _G or env
|
||||
on-pressed: |
|
||||
print("hello") -- raw Lua string, loaded as chunk
|
||||
]]
|
||||
local function resolve(v)
|
||||
if type(v) == "function" then return v end
|
||||
if type(v) == "string" then
|
||||
-- Try global lookup first
|
||||
if _G[v] and type(_G[v]) == "function" then return _G[v] end
|
||||
-- Otherwise treat as Lua source
|
||||
if env then
|
||||
return env[v]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local map = {
|
||||
["on-pressed"] = "OnPressed",
|
||||
["on-released"] = "OnReleased",
|
||||
["on-released-outer"] = "OnReleasedOuter",
|
||||
["on-pressed-outer"] = "OnPressedOuter",
|
||||
["on-enter"] = "OnEnter",
|
||||
["on-exit"] = "OnExit",
|
||||
["on-moved"] = "OnMoved",
|
||||
["on-drag-start"] = "OnDragStart",
|
||||
["on-dragging"] = "OnDragging",
|
||||
["on-drag-end"] = "OnDragEnd",
|
||||
["on-wheel"] = "OnWheelMoved",
|
||||
["on-size-changed"] = "OnSizeChanged",
|
||||
["on-position-changed"] = "OnPositionChanged",
|
||||
["on-destroy"] = "OnDestroy",
|
||||
["on-load"] = "OnLoad",
|
||||
["on-return"] = "OnReturn", -- textbox only
|
||||
}
|
||||
|
||||
for yaml_key, conn_key in pairs(map) do
|
||||
if def[yaml_key] and obj[conn_key] then
|
||||
local fn = resolve(def[yaml_key])
|
||||
if fn then obj[conn_key](fn) end
|
||||
end
|
||||
end
|
||||
|
||||
-- on-update is special (not a connection)
|
||||
if def["on-update"] then
|
||||
local fn = resolve(def["on-update"])
|
||||
if fn then obj:OnUpdate(fn) end
|
||||
end
|
||||
|
||||
-- Hotkeys
|
||||
if def.hotkeys then
|
||||
for _, hk in ipairs(def.hotkeys) do
|
||||
-- {keys: [lctrl, s], action: "mySaveFunction"}
|
||||
local fn = resolve(hk.action)
|
||||
if fn then obj:setHotKey(hk.keys)(fn) end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function applyTextProps(obj, def)
|
||||
if def.text then obj.text = tostring(def.text) end
|
||||
if def["text-color"] then obj.textColor = parseColor(def["text-color"]) end
|
||||
if def["text-visibility"] then obj.textVisibility = def["text-visibility"] end
|
||||
if def["text-scale"] then
|
||||
obj.textScaleX = def["text-scale"][1] or 1
|
||||
obj.textScaleY = def["text-scale"][2] or 1
|
||||
end
|
||||
if def["text-offset"] then
|
||||
obj.textOffsetX = def["text-offset"][1] or 0
|
||||
obj.textOffsetY = def["text-offset"][2] or 0
|
||||
end
|
||||
if def["text-shear"] then
|
||||
obj.textShearingFactorX = def["text-shear"][1] or 0
|
||||
obj.textShearingFactorY = def["text-shear"][2] or 0
|
||||
end
|
||||
|
||||
-- Alignment
|
||||
local alignMap = {left = 1, center = 0, right = 2}
|
||||
if def.align then
|
||||
obj.align = alignMap[def.align] or 1
|
||||
end
|
||||
|
||||
-- Font
|
||||
if def.font then
|
||||
local f = def.font
|
||||
if type(f) == "number" then
|
||||
obj:setFont(f)
|
||||
elseif type(f) == "string" then
|
||||
obj:setFont(f, def["font-size"])
|
||||
elseif type(f) == "table" then
|
||||
-- {file: "fonts/roboto.ttf", size: 18}
|
||||
obj:setFont(f.file or f[1], f.size or f[2])
|
||||
end
|
||||
end
|
||||
|
||||
-- fit-font: true | {min: 8, max: 200, scale: 1}
|
||||
if def["fit-font"] then
|
||||
local ff = def["fit-font"]
|
||||
if ff == true then
|
||||
obj:fitFont()
|
||||
elseif type(ff) == "table" then
|
||||
obj:fitFont(ff.min, ff.max, ff.scale and {scale=ff.scale} or nil)
|
||||
end
|
||||
end
|
||||
|
||||
-- center-font: true | offset
|
||||
if def["center-font"] then
|
||||
local cf = def["center-font"]
|
||||
obj:centerFont(type(cf) == "number" and cf or nil)
|
||||
end
|
||||
end
|
||||
|
||||
local function applyImageProps(obj, def)
|
||||
-- source: "path/to/image.png"
|
||||
-- tile: [x, y, w, h] (optional sub-quad)
|
||||
if def.source then
|
||||
if def.tile then
|
||||
local t = def.tile
|
||||
obj:setImage(def.source, t[1], t[2], t[3], t[4])
|
||||
else
|
||||
obj:setImage(def.source)
|
||||
end
|
||||
end
|
||||
|
||||
if def["scale-x"] then obj.scaleX = def["scale-x"] end
|
||||
if def["scale-y"] then obj.scaleY = def["scale-y"] end
|
||||
if def["image-color"] then obj.imageColor = parseColor(def["image-color"]) end
|
||||
if def["image-visibility"] then obj.imageVisibility = def["image-visibility"] end
|
||||
|
||||
-- flip: "horizontal" | "vertical" | "both"
|
||||
if def.flip then
|
||||
local fl = def.flip
|
||||
if fl == "horizontal" or fl == "both" then obj:flip(false) end
|
||||
if fl == "vertical" or fl == "both" then obj:flip(true) end
|
||||
end
|
||||
|
||||
-- gradient shorthand
|
||||
if def.gradient then
|
||||
local g = def.gradient
|
||||
-- {direction: "vertical", colors: [[r,g,b,a], ...]}
|
||||
local colors = {}
|
||||
for _, c in ipairs(g.colors) do
|
||||
colors[#colors+1] = parseColor(c)
|
||||
end
|
||||
obj:applyGradient(g.direction or "vertical", table.unpack(colors))
|
||||
end
|
||||
end
|
||||
|
||||
local function applyVideoProps(obj, def)
|
||||
if def.source then obj:setVideo(def.source) end
|
||||
if def.volume then obj:setVolume(def.volume) end
|
||||
if def.autoplay and def.autoplay then obj:play() end
|
||||
if def["video-color"] then obj.videoColor = parseColor(def["video-color"]) end
|
||||
if def["video-visibility"] then obj.videoVisibility = def["video-visibility"] end
|
||||
end
|
||||
|
||||
-- ─────────────────────────────────────────────
|
||||
-- Core builder
|
||||
-- ─────────────────────────────────────────────
|
||||
|
||||
local builders -- forward ref for recursion
|
||||
|
||||
builders = {
|
||||
["frame"] = function(parent, def)
|
||||
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
|
||||
return parent:newFrame(x,y,w,h,sx,sy,sw,sh)
|
||||
end,
|
||||
["virtual-frame"] = function(parent, def)
|
||||
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
|
||||
return parent:newVirtualFrame(x,y,w,h,sx,sy,sw,sh)
|
||||
end,
|
||||
["visual-frame"] = function(parent, def)
|
||||
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
|
||||
return parent:newVisualFrame(x,y,w,h,sx,sy,sw,sh)
|
||||
end,
|
||||
["label"] = function(parent, def)
|
||||
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
|
||||
return parent:newTextLabel(def.text or "", x,y,w,h,sx,sy,sw,sh)
|
||||
end,
|
||||
["button"] = function(parent, def)
|
||||
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
|
||||
return parent:newTextButton(def.text or "", x,y,w,h,sx,sy,sw,sh)
|
||||
end,
|
||||
["textbox"] = function(parent, def)
|
||||
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
|
||||
return parent:newTextBox(def.text or "", x,y,w,h,sx,sy,sw,sh)
|
||||
end,
|
||||
["image"] = function(parent, def)
|
||||
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
|
||||
return parent:newImageLabel(def.source, x,y,w,h,sx,sy,sw,sh)
|
||||
end,
|
||||
["image-button"] = function(parent, def)
|
||||
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
|
||||
return parent:newImageButton(def.source, x,y,w,h,sx,sy,sw,sh)
|
||||
end,
|
||||
["video"] = function(parent, def)
|
||||
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
|
||||
return parent:newVideo(def.source, x,y,w,h,sx,sy,sw,sh)
|
||||
end,
|
||||
}
|
||||
|
||||
local TEXT_TYPES = {label=true, button=true, textbox=true}
|
||||
local IMAGE_TYPES = {image=true, ["image-button"]=true}
|
||||
local VIDEO_TYPES = {video=true}
|
||||
|
||||
function gui_yaml.build(parent, def, env)
|
||||
--[[
|
||||
parent : gui element or gui root
|
||||
def : parsed YAML table (one element)
|
||||
env : optional Lua env table for event string resolution
|
||||
|
||||
Returns the created object (or nil on unknown type).
|
||||
]]
|
||||
local typ = def.type
|
||||
if not typ then
|
||||
error("gui_yaml: element missing 'type' field")
|
||||
end
|
||||
|
||||
local builder = builders[typ]
|
||||
if not builder then
|
||||
error("gui_yaml: unknown element type '" .. tostring(typ) .. "'")
|
||||
end
|
||||
|
||||
local obj = builder(parent, def)
|
||||
|
||||
-- Apply shared properties
|
||||
applyShared(parent, obj, def)
|
||||
|
||||
-- Apply type-specific properties
|
||||
if TEXT_TYPES[typ] then applyTextProps(obj, def) end
|
||||
if IMAGE_TYPES[typ] then applyImageProps(obj, def) end
|
||||
if VIDEO_TYPES[typ] then applyVideoProps(obj, def) end
|
||||
|
||||
-- Events
|
||||
applyEvents(obj, def, env)
|
||||
|
||||
-- Recurse into children
|
||||
if def.children then
|
||||
for _, child_def in ipairs(def.children) do
|
||||
gui_yaml.build(obj, child_def, env)
|
||||
end
|
||||
end
|
||||
|
||||
return obj
|
||||
end
|
||||
|
||||
function gui_yaml.buildMany(parent, defs, env)
|
||||
local results = {}
|
||||
for _, def in ipairs(defs) do
|
||||
results[#results+1] = gui_yaml.build(parent, def, env)
|
||||
end
|
||||
return results
|
||||
end
|
||||
|
||||
-- Convenience: parse a YAML string and build in one call.
|
||||
-- Requires a YAML library. Tries tinyyaml, then lyaml.
|
||||
function gui_yaml.fromString(parent, yaml_str, env)
|
||||
local ok, yaml = pcall(require, "gui.yaml.tinyyaml")
|
||||
if not ok then
|
||||
ok, yaml = pcall(require, "lyaml")
|
||||
if not ok then
|
||||
error("gui_yaml.fromString: no YAML library found (tried tinyyaml, lyaml)")
|
||||
end
|
||||
end
|
||||
local def = yaml.parse(yaml_str)
|
||||
-- Support both single-element and list-of-elements at root
|
||||
if def.type then
|
||||
return gui_yaml.build(parent, def, env)
|
||||
else
|
||||
return gui_yaml.buildMany(parent, def, env)
|
||||
end
|
||||
end
|
||||
|
||||
return gui_yaml
|
||||
776
gui/yaml/tinyyaml.lua
Normal file
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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>▶ Lua HTTP Server</h1>
|
||||
<p>Bidirectional client ↔ 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)} | <b>Port:</b> ${esc(data.port)}<br>` +
|
||||
`<b>Uptime:</b> ${esc(data.uptime)}s | <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,"&").replace(/</g,"<")
|
||||
.replace(/>/g,">").replace(/"/g,""");
|
||||
}
|
||||
|
||||
// ── 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
@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"filesystem": {
|
||||
"source": "oimiragieo/agent-studio",
|
||||
"sourceType": "github",
|
||||
"computedHash": "70b2b09227ca549eac68abeefa5ca2bfba0d158da5faec7be5de875282fc7d28"
|
||||
}
|
||||
}
|
||||
}
|
||||
56
templates/one-image.lua
Executable 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
@ -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
timesup.mp3
Normal file
108
utils.lua
Executable 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
@ -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
|
||||
776
yaml.lua
Executable 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,
|
||||
}
|
||||