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,
|
||||||
|
}
|
||||||