added logging support

This commit is contained in:
2026-06-08 20:29:59 -07:00
parent 0c90ab885e
commit 6b864a2d9b
15 changed files with 1736 additions and 1073 deletions
+355
View File
@@ -0,0 +1,355 @@
--[[
fmt.lua - Go's fmt.Printf() style formatting for Lua
Supported verbs:
%v - default format (tostring)
%T - type of the value
%t - boolean (true/false)
%d - integer (decimal)
%b - integer (binary)
%o - integer (octal)
%x - integer (hex, lowercase)
%X - integer (hex, uppercase)
%e - float (scientific, lowercase)
%E - float (scientific, uppercase)
%f - float (decimal point)
%g - float (%e for large, %f otherwise)
%G - float (%E for large, %f otherwise)
%s - string
%q - quoted string (Lua-escaped)
%c - character (from integer codepoint)
%% - literal percent sign
Width & precision:
%8d - right-aligned, width 8
%-8d - left-aligned, width 8
%08d - zero-padded, width 8
%8.2f - width 8, 2 decimal places
%.5s - truncate string to 5 chars
%*d - width from next argument
%.*f - precision from next argument
--]]
local fmt = {}
-- ── helpers ──────────────────────────────────────────────────────────────────
local function to_int(v)
local n = math.tointeger and math.tointeger(v) or (type(v) == "number" and math.floor(v) or nil)
if n == nil then
error(string.format("fmt: expected integer, got %s (%s)", type(v), tostring(v)), 3)
end
return n
end
local function to_num(v)
if type(v) ~= "number" then
error(string.format("fmt: expected number, got %s (%s)", type(v), tostring(v)), 3)
end
return v
end
-- Convert integer to binary string
local function to_binary(n)
n = to_int(n)
if n == 0 then return "0" end
local neg = n < 0
if neg then n = -n end
local bits = {}
while n > 0 do
table.insert(bits, 1, n % 2)
n = math.floor(n / 2)
end
local s = table.concat(bits)
return neg and "-" .. s or s
end
-- Apply width/alignment padding
local function apply_width(s, width, left_align, zero_pad, is_numeric)
if not width or width == 0 or #s >= width then return s end
local pad_char = (zero_pad and is_numeric) and "0" or " "
local pad = string.rep(pad_char, width - #s)
if zero_pad and is_numeric then
-- keep sign before zeros: "-007"
if s:sub(1,1) == "-" then
return "-" .. pad .. s:sub(2)
end
return pad .. s
end
return left_align and (s .. pad) or (pad .. s)
end
-- Apply precision to a string (truncation)
local function apply_str_precision(s, prec)
if prec and #s > prec then
return s:sub(1, prec)
end
return s
end
-- Quoted / escaped string (like Go %q)
local function quote_string(s)
s = s:gsub('\\', '\\\\')
s = s:gsub('"', '\\"')
s = s:gsub('\n', '\\n')
s = s:gsub('\r', '\\r')
s = s:gsub('\t', '\\t')
s = s:gsub('%z', '\\0')
return '"' .. s .. '"'
end
-- ── core formatter ────────────────────────────────────────────────────────────
--[[
fmt.Sprintf(format, ...) → string
Returns the formatted string without printing it.
--]]
function fmt.Sprintf(format, ...)
local args = { ... }
local ai = 1 -- argument index
local out = {} -- output buffer
local i = 1
local len = #format
while i <= len do
local c = format:sub(i, i)
if c ~= "%" then
out[#out+1] = c
i = i + 1
else
i = i + 1
if i > len then
error("fmt: trailing '%' in format string", 2)
end
-- %% literal
if format:sub(i, i) == "%" then
out[#out+1] = "%"
i = i + 1
else
-- ── parse flags ───────────────────────────────────────────
local left_align = false
local zero_pad = false
local plus_sign = false
local space_sign = false
local hash_flag = false
while i <= len do
local f = format:sub(i, i)
if f == "-" then left_align = true
elseif f == "0" then zero_pad = true
elseif f == "+" then plus_sign = true
elseif f == " " then space_sign = true
elseif f == "#" then hash_flag = true
else break
end
i = i + 1
end
-- ── parse width ───────────────────────────────────────────
local width = nil
if i <= len and format:sub(i, i) == "*" then
width = to_int(args[ai]); ai = ai + 1
i = i + 1
else
local w = format:match("^%d+", i)
if w then width = tonumber(w); i = i + #w end
end
-- ── parse precision ───────────────────────────────────────
local prec = nil
if i <= len and format:sub(i, i) == "." then
i = i + 1
if i <= len and format:sub(i, i) == "*" then
prec = to_int(args[ai]); ai = ai + 1
i = i + 1
else
local p = format:match("^%d*", i)
prec = tonumber(p) or 0
i = i + #p
end
end
-- ── parse verb ────────────────────────────────────────────
if i > len then
error("fmt: missing verb in format string", 2)
end
local verb = format:sub(i, i)
i = i + 1
local arg = args[ai]; ai = ai + 1
local s = ""
local is_numeric = false
-- %v default
if verb == "v" then
s = tostring(arg)
-- %T type
elseif verb == "T" then
s = type(arg)
-- %t boolean
elseif verb == "t" then
s = arg and "true" or "false"
-- %d decimal integer
elseif verb == "d" then
is_numeric = true
local n = to_int(arg)
s = tostring(math.abs(n))
if prec then s = string.rep("0", math.max(0, prec - #s)) .. s end
if n < 0 then s = "-" .. s
elseif plus_sign then s = "+" .. s
elseif space_sign then s = " " .. s
end
-- %b binary
elseif verb == "b" then
is_numeric = true
s = to_binary(arg)
if hash_flag then s = "0b" .. s end
-- %o octal
elseif verb == "o" then
is_numeric = true
local n = to_int(arg)
local neg = n < 0
s = string.format("%o", math.abs(n))
if prec then s = string.rep("0", math.max(0, prec - #s)) .. s end
if hash_flag and s:sub(1,1) ~= "0" then s = "0" .. s end
if neg then s = "-" .. s end
-- %x hex lowercase
elseif verb == "x" then
is_numeric = true
local n = to_int(arg)
local neg = n < 0
s = string.format("%x", math.abs(n))
if prec then s = string.rep("0", math.max(0, prec - #s)) .. s end
if hash_flag then s = "0x" .. s end
if neg then s = "-" .. s end
-- %X hex uppercase
elseif verb == "X" then
is_numeric = true
local n = to_int(arg)
local neg = n < 0
s = string.format("%X", math.abs(n))
if prec then s = string.rep("0", math.max(0, prec - #s)) .. s end
if hash_flag then s = "0X" .. s end
if neg then s = "-" .. s end
-- %e scientific lowercase
elseif verb == "e" then
is_numeric = true
local p = prec ~= nil and prec or 6
s = string.format("%." .. p .. "e", to_num(arg))
if plus_sign and arg >= 0 then s = "+" .. s end
-- %E scientific uppercase
elseif verb == "E" then
is_numeric = true
local p = prec ~= nil and prec or 6
s = string.format("%." .. p .. "E", to_num(arg))
if plus_sign and arg >= 0 then s = "+" .. s end
-- %f decimal float
elseif verb == "f" then
is_numeric = true
local p = prec ~= nil and prec or 6
s = string.format("%." .. p .. "f", to_num(arg))
if plus_sign and arg >= 0 then s = "+" .. s end
if space_sign and arg >= 0 then s = " " .. s end
-- %g / %G shortest float representation
elseif verb == "g" or verb == "G" then
is_numeric = true
local p = prec ~= nil and prec or -1
local n = to_num(arg)
local abs_n = math.abs(n)
if p == -1 then
-- mimic Go: use %e if exponent < -4 or >= precision(default 6)
if abs_n ~= 0 and (abs_n < 1e-4 or abs_n >= 1e6) then
s = string.format(verb == "G" and "%.6E" or "%.6e", n)
else
s = string.format("%.6g", n)
if verb == "G" then s = s:upper() end
end
else
s = string.format("%." .. p .. (verb == "G" and "G" or "g"), n)
end
if plus_sign and n >= 0 then s = "+" .. s end
-- %s string
elseif verb == "s" then
s = tostring(arg)
s = apply_str_precision(s, prec)
-- %q quoted string
elseif verb == "q" then
s = quote_string(tostring(arg))
-- %c character
elseif verb == "c" then
local n = to_int(arg)
-- utf8.char available in Lua 5.3+
if utf8 then
s = utf8.char(n)
else
s = string.char(n) -- ASCII only fallback
end
else
error(string.format("fmt: unknown verb %%%s", verb), 2)
end
-- Apply width padding
s = apply_width(s, width, left_align, zero_pad, is_numeric)
out[#out+1] = s
end
end
end
return table.concat(out)
end
--[[
fmt.Printf(format, ...)
Prints to stdout (no trailing newline, just like Go).
--]]
function fmt.Printf(format, ...)
io.write(fmt.Sprintf(format, ...))
end
--[[
fmt.Println(...)
Prints args separated by spaces with a trailing newline.
--]]
function fmt.Println(...)
local parts = {}
for i = 1, select("#", ...) do
parts[i] = tostring(select(i, ...))
end
print(table.concat(parts, " "))
end
--[[
fmt.Fprintf(file, format, ...)
Writes formatted output to a file handle.
--]]
function fmt.Fprintf(file, format, ...)
file:write(fmt.Sprintf(format, ...))
end
--[[
fmt.Errorf(format, ...) → string
Returns a formatted error string (for use with error()).
--]]
function fmt.Errorf(format, ...)
return fmt.Sprintf(format, ...)
end
return fmt
+41
View File
@@ -0,0 +1,41 @@
local yaml = require("utils/yaml")
local loader = {}
loader.__index = loader
local loaderLog = log:child("loader")
function loader:new(path_or_file)
local c = {}
setmetatable(c, loader)
c.source = path_or_file
local file = love.filesystem.read(path_or_file .. "/index.yml")
if not file then
loaderLog:error("Unable to load file: %s/index.yml", path_or_file)
end
c.index = yaml.parse(file)
for i,v in pairs(c.index.categories) do
local link = love.filesystem.read(path_or_file .. "/" .. v.name .. ".yml")
if not link then
loaderLog:error("Error! Cannot find file: %s/%s.yml", path_or_file,v.name)
else
local category = yaml.parse(link)
c.index.categories[i].questions = category.questions
c.index.categories[i].global = category.global or {}
c.index.categories[i].cosmetics = category.cosmetics or {}
end
end
return c
end
function loader:getCategories()
return self.index.categories
end
function loader:getSettings()
return self.index.settings
end
return loader
+300
View File
@@ -0,0 +1,300 @@
--- logger.lua — A feature-rich logging library for Lua
-- Supports log levels, multiple handlers, formatting, and filtering.
--
-- Usage:
-- local Logger = require("logger")
-- local log = Logger.new("MyApp")
-- log:info("Server started on port %d", 8080)
-- log:warn("Disk usage at %d%%", 92)
-- log:error("Connection refused: %s", err)
local Logger = {}
Logger.__index = Logger
-- ── Level definitions ──────────────────────────────────────────────────────
Logger.LEVELS = {
TRACE = 1,
DEBUG = 2,
INFO = 3,
WARN = 4,
ERROR = 5,
FATAL = 6,
OFF = 7, -- disable all output
}
-- Reverse map: number → name
local LEVEL_NAMES = {}
for name, val in pairs(Logger.LEVELS) do
LEVEL_NAMES[val] = name
end
-- ANSI colour codes (used by the ConsoleHandler when colours are enabled)
local COLOURS = {
[1] = "\27[36m", -- TRACE cyan
[2] = "\27[34m", -- DEBUG blue
[3] = "\27[32m", -- INFO green
[4] = "\27[33m", -- WARN yellow
[5] = "\27[31m", -- ERROR red
[6] = "\27[35m", -- FATAL magenta
RESET = "\27[0m",
}
-- ── Utility helpers ────────────────────────────────────────────────────────
--- Returns an ISO-8601-style timestamp: "2025-06-08 14:32:01"
local function timestamp()
return os.date("%Y-%m-%d %H:%M:%S")
end
--- Safe string formatting — falls back gracefully when fmt is not a string
local function safe_format(fmt, ...)
if select("#", ...) == 0 then
return tostring(fmt)
end
local ok, result = pcall(string.format, tostring(fmt), ...)
return ok and result or (tostring(fmt) .. " " .. table.concat({...}, " "))
end
--- Pads or truncates a string to a fixed width
local function pad(s, width)
s = tostring(s)
if #s >= width then return s:sub(1, width) end
return s .. string.rep(" ", width - #s)
end
-- ── Built-in Handlers ──────────────────────────────────────────────────────
--- ConsoleHandler — writes to stdout (or stderr for ERROR+)
-- Options:
-- colours (bool, default true) — enable ANSI colours
-- stderr (bool, default false) — always write to stderr
local function ConsoleHandler(opts)
opts = opts or {}
local use_colour = (opts.colours ~= false)
local force_stderr = opts.stderr or false
return function(record)
local line = record.formatted
if use_colour then
local c = COLOURS[record.level] or ""
line = c .. line .. COLOURS.RESET
end
local dest = (force_stderr or record.level >= Logger.LEVELS.ERROR) and io.stderr or io.stdout
dest:write(line .. "\n")
dest:flush()
end
end
--- FileHandler — appends log records to a file
-- Options:
-- path (string, required) — path to the log file
-- max_size (number, optional) — rotate when file exceeds this many bytes
-- max_files(number, optional) — keep this many rotated files (default 5)
local function FileHandler(opts)
assert(opts and opts.path, "FileHandler requires opts.path")
local path = opts.path
local max_size = opts.max_size -- nil = never rotate
local max_files = opts.max_files or 5
local file
local function open_file()
file = assert(io.open(path, "a"), "Cannot open log file: " .. path)
end
local function rotate()
if file then file:close(); file = nil end
-- shift old files: .4 → .5, .3 → .4, …, .1 → .2, base → .1
for i = max_files - 1, 1, -1 do
os.rename(path .. "." .. i, path .. "." .. (i + 1))
end
os.rename(path, path .. ".1")
open_file()
end
open_file()
return function(record)
if not file then open_file() end
file:write(record.formatted .. "\n")
file:flush()
if max_size then
local pos = file:seek("cur")
if pos and pos >= max_size then rotate() end
end
end
end
--- MemoryHandler — stores records in a Lua table (useful for testing)
-- Returns handler, get_records() function
local function MemoryHandler(opts)
opts = opts or {}
local limit = opts.limit or 10000
local records = {}
local handler = function(record)
if #records >= limit then table.remove(records, 1) end
table.insert(records, record)
end
local function get_records() return records end
local function clear() records = {} end
return handler, get_records, clear
end
-- Expose built-ins
Logger.handlers = {
ConsoleHandler = ConsoleHandler,
FileHandler = FileHandler,
MemoryHandler = MemoryHandler,
}
-- ── Formatter ─────────────────────────────────────────────────────────────
-- Tokens in the format string:
-- {time} — timestamp
-- {level} — padded level name (5 chars)
-- {name} — logger name
-- {message} — the log message
local DEFAULT_FORMAT = "[{time}] [{level}] [{name}] {message}"
local function escape_replacement(s)
-- In Lua's gsub, '%' in the replacement string is special; escape it.
return (s:gsub("%%", "%%%%"))
end
local function apply_format(fmt, record)
return (fmt
:gsub("{time}", escape_replacement(record.time))
:gsub("{level}", escape_replacement(pad(record.level_name, 5)))
:gsub("{name}", escape_replacement(record.name))
:gsub("{message}", escape_replacement(record.message)))
end
-- ── Logger constructor ─────────────────────────────────────────────────────
--- Create a new logger instance.
-- @param name string — logger name shown in output
-- @param opts table — optional configuration:
-- level (number) — minimum level, default INFO
-- format (string) — format string, default DEFAULT_FORMAT
-- handlers (table) — list of handler functions; defaults to ConsoleHandler
function Logger.new(name, opts)
opts = opts or {}
local self = setmetatable({}, Logger)
self.name = name or "root"
self.level = opts.level or Logger.LEVELS.INFO
self.format = opts.format or DEFAULT_FORMAT
self._handlers = opts.handlers or { ConsoleHandler() }
self._filters = {} -- list of predicate functions
return self
end
-- ── Configuration API ──────────────────────────────────────────────────────
--- Set the minimum log level (Logger.LEVELS.*)
function Logger:set_level(level)
assert(type(level) == "number", "level must be a number from Logger.LEVELS")
self.level = level
return self
end
--- Add a handler function
function Logger:add_handler(handler)
table.insert(self._handlers, handler)
return self
end
--- Remove all handlers and replace with the given ones
function Logger:set_handlers(handlers)
self._handlers = handlers
return self
end
--- Add a filter predicate — a function(record) → bool
-- If ANY filter returns false the record is dropped.
function Logger:add_filter(fn)
table.insert(self._filters, fn)
return self
end
--- Set the output format string
function Logger:set_format(fmt)
self.format = fmt
return self
end
-- ── Core emit logic ────────────────────────────────────────────────────────
function Logger:_emit(level, fmt, ...)
if level < self.level then return end
local record = {
time = timestamp(),
level = level,
level_name = LEVEL_NAMES[level] or ("LVL" .. level),
name = self.name,
message = safe_format(fmt, ...),
}
record.formatted = apply_format(self.format, record)
-- run filters
for _, filter in ipairs(self._filters) do
if not filter(record) then return end
end
-- dispatch to handlers
for _, handler in ipairs(self._handlers) do
local ok, err = pcall(handler, record)
if not ok then
io.stderr:write("[logger] handler error: " .. tostring(err) .. "\n")
end
end
end
-- ── Convenience level methods ──────────────────────────────────────────────
function Logger:trace(fmt, ...) self:_emit(Logger.LEVELS.TRACE, fmt, ...) end
function Logger:debug(fmt, ...) self:_emit(Logger.LEVELS.DEBUG, fmt, ...) end
function Logger:info (fmt, ...) self:_emit(Logger.LEVELS.INFO, fmt, ...) end
function Logger:warn (fmt, ...) self:_emit(Logger.LEVELS.WARN, fmt, ...) end
function Logger:error(fmt, ...) self:_emit(Logger.LEVELS.ERROR, fmt, ...) end
function Logger:fatal(fmt, ...) self:_emit(Logger.LEVELS.FATAL, fmt, ...) end
--- Log at a given numeric level
function Logger:log(level, fmt, ...)
self:_emit(level, fmt, ...)
end
--- Returns true if the given level would be emitted
function Logger:is_enabled(level)
return level >= self.level
end
-- ── Child loggers ──────────────────────────────────────────────────────────
--- Create a child logger that inherits handlers/format but has its own name.
function Logger:child(name, opts)
opts = opts or {}
return Logger.new(self.name .. "." .. name, {
level = opts.level or self.level,
format = opts.format or self.format,
handlers = opts.handlers or self._handlers,
})
end
-- ── Module-level root logger ───────────────────────────────────────────────
Logger._root = Logger.new("root")
--- Get / lazily create a named logger backed by the root's handlers.
local _registry = {}
function Logger.get(name)
if not _registry[name] then
_registry[name] = Logger._root:child(name)
end
return _registry[name]
end
return Logger
+222
View File
@@ -0,0 +1,222 @@
--- love_file_handler.lua — LÖVE2D file handler for logger.lua
--
-- Uses love.filesystem to write logs into LÖVE's save directory, which works
-- correctly on every platform LÖVE supports (Windows, macOS, Linux, Android).
--
-- Requires:
-- logger.lua in the same directory (or on package.path)
-- LÖVE2D 11.x+ (love.filesystem must be available)
--
-- Usage inside main.lua / conf.lua:
--
-- local Logger = require("logger")
-- local LoveHandler = require("love_file_handler")
--
-- local log = Logger.new("game", {
-- handlers = {
-- Logger.handlers.ConsoleHandler(), -- print() fallback
-- LoveHandler.new("logs/game.log"), -- love.filesystem
-- },
-- })
--
-- function love.load()
-- log:info("Game started — LÖVE %s", love.getVersion())
-- end
-- ── Sanity check ──────────────────────────────────────────────────────────
assert(love and love.filesystem,
"love_file_handler requires the LÖVE2D runtime (love.filesystem)")
-- ── Module table ──────────────────────────────────────────────────────────
local LoveHandler = {}
-- ── Helpers ───────────────────────────────────────────────────────────────
--- Ensure every directory component in `path` exists.
-- love.filesystem.createDirectory is recursive on LÖVE 11+.
local function ensure_dir(path)
local dir = path:match("^(.*)/[^/]+$")
if dir and dir ~= "" then
love.filesystem.createDirectory(dir)
end
end
--- Return current file size in bytes, or 0 on error / missing file.
local function file_size(path)
local info = love.filesystem.getInfo(path)
return info and info.size or 0
end
--- Shift rotated files: .4→.5, .3→.4, …, base→.1
local function rotate(path, max_files)
for i = max_files - 1, 1, -1 do
local src = path .. "." .. i
local dest = path .. "." .. (i + 1)
if love.filesystem.getInfo(src) then
-- love.filesystem has no rename; copy + delete
local data = love.filesystem.read(src)
if data then
love.filesystem.write(dest, data)
love.filesystem.remove(src)
end
end
end
-- move base → .1
if love.filesystem.getInfo(path) then
local data = love.filesystem.read(path)
if data then
love.filesystem.write(path .. ".1", data)
love.filesystem.remove(path)
end
end
end
-- ── Constructor ───────────────────────────────────────────────────────────
--- Create a new LÖVE2D file handler.
--
-- @param path string Path inside the LÖVE save directory.
-- Subdirectories are created automatically.
-- Default: "logs/game.log"
--
-- @param opts table Optional settings:
-- max_size (number) Rotate when the log file reaches this many bytes.
-- Default: 1 MB (1 048 576). Set to 0 to disable.
-- max_files (number) How many rotated files to keep. Default: 5.
-- buffered (bool) Accumulate lines in memory and flush in batches.
-- Reduces I/O overhead on mobile / web exports.
-- Default: false.
-- flush_size (number) Lines to buffer before auto-flushing (buffered mode).
-- Default: 20.
-- on_error (func) Called with (err_string) on any I/O error.
-- Default: prints a warning to love.graphics console.
--
-- @return handler, control_table
-- handler — function accepted by Logger:add_handler()
-- control — table with :flush(), :close(), :path(), :size() methods
function LoveHandler.new(path, opts)
path = path or "logs/game.log"
opts = opts or {}
local max_size = (opts.max_size == nil) and (1024 * 1024) or opts.max_size
local max_files = opts.max_files or 5
local buffered = opts.buffered or false
local flush_size = opts.flush_size or 20
local on_error = opts.on_error or function(err)
if love.graphics and love.graphics.print then
print("[love_file_handler] I/O error: " .. tostring(err))
end
end
-- State
local buffer = {} -- pending lines (buffered mode)
local closed = false
ensure_dir(path)
-- ── Internal write ────────────────────────────────────────────────────
local function raw_write(lines)
if #lines == 0 then return end
local text = table.concat(lines, "\n") .. "\n"
-- Rotate if needed
if max_size and max_size > 0 and file_size(path) + #text >= max_size then
rotate(path, max_files)
end
-- Append
local ok, err = love.filesystem.append(path, text)
if not ok then
on_error(tostring(err))
end
end
-- ── Public control table ──────────────────────────────────────────────
local control = {}
--- Flush buffered lines to disk immediately.
function control:flush()
if #buffer > 0 then
raw_write(buffer)
buffer = {}
end
end
--- Close the handler (flushes first). After close() the handler is a no-op.
function control:close()
self:flush()
closed = true
end
--- Full path as seen by love.filesystem.
function control:path()
return path
end
--- Real filesystem path to the save directory file (for external tools).
function control:real_path()
local save_dir = love.filesystem.getSaveDirectory()
return save_dir .. "/" .. path
end
--- Current log file size in bytes.
function control:size()
return file_size(path)
end
--- List rotated files that exist (e.g. {"logs/game.log.1", ...})
function control:rotated_files()
local result = {}
for i = 1, max_files do
local p = path .. "." .. i
if love.filesystem.getInfo(p) then
table.insert(result, p)
end
end
return result
end
--- Read the entire current log file as a string (nil on error).
function control:read()
self:flush()
return love.filesystem.read(path)
end
-- ── Handler function ──────────────────────────────────────────────────
local function handler(record)
if closed then return end
if buffered then
table.insert(buffer, record.formatted)
if #buffer >= flush_size then
raw_write(buffer)
buffer = {}
end
else
raw_write({ record.formatted })
end
end
return handler, control
end
-- ── Convenience: hook love.quit to flush ──────────────────────────────────
--- Wrap love.quit so a control table's flush() is called automatically.
-- Call once per handler after Logger.new(); safe to call multiple times.
--
-- @param control The control table returned by LoveHandler.new()
function LoveHandler.auto_flush_on_quit(control)
local original_quit = love.quit or function() end
love.quit = function()
control:flush()
return original_quit()
end
end
return LoveHandler
+134
View File
@@ -0,0 +1,134 @@
local gui = require("gui")
local color = require("gui.core.color")
local multi, thread = 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
else
for i, v in pairs(opt) do
default[i] = v
end
opt = default
end
local timeRemaining = opt.duration or 30
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
local stopped = false
local onTimeConn = multi:newConnection()
local onStopConn = multi:newConnection()
local function cleanup()
if stopped then return end
stopped = true
if onTimeConn and not onTimeConn.destroyed then
onTimeConn:Destroy()
end
if not tpie.destroyed then
tpie:destroy()
end
end
local timerThread = thread:newThread("Pie Timer", function()
local startTime = love.timer.getTime()
local lastSecond = timeRemaining
while not stopped do
thread.sleep(1/60)
local now = love.timer.getTime()
local elapsed = now - startTime
local t = math.min(elapsed, timeRemaining)
local num = timeRemaining - math.floor(t)
local p = 3.5 - (t / timeRemaining) * 2
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 opt.finegrained then
onTimeConn:Fire(tm, num, t)
elseif num < lastSecond then
-- crossed a second boundary
lastSecond = num
onTimeConn:Fire(tm, num, t)
end
if elapsed >= timeRemaining then
break
end
end
-- Timer finished
if stopped then return end
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)
local onStop = onStopConn
cleanup()
onStop:Fire(tm)
end)
tm = {
Duration = timeRemaining,
OnTime = onTimeConn,
OnStop = onStopConn,
SetText = function(str) tlabel.text = str end,
SetColor = function(c) tpie.color = c end,
Cleanup = function()
timerThread:Kill()
cleanup()
end,
}
return tm
end
return {
startTimer = startTimer
}
+776
View File
@@ -0,0 +1,776 @@
-------------------------------------------------------------------------------
-- tinyyaml - YAML subset parser
-------------------------------------------------------------------------------
local table = table
local string = string
local schar = string.char
local ssub, gsub = string.sub, string.gsub
local sfind, smatch = string.find, string.match
local tinsert, tremove = table.insert, table.remove
local setmetatable = setmetatable
local pairs = pairs
local type = type
local tonumber = tonumber
local math = math
local getmetatable = getmetatable
local error = error
local UNESCAPES = {
['0'] = "\x00", z = "\x00", N = "\x85",
a = "\x07", b = "\x08", t = "\x09",
n = "\x0a", v = "\x0b", f = "\x0c",
r = "\x0d", e = "\x1b", ['\\'] = '\\',
};
-------------------------------------------------------------------------------
-- utils
local function select(list, pred)
local selected = {}
for i = 0, #list do
local v = list[i]
if v and pred(v, i) then
tinsert(selected, v)
end
end
return selected
end
local function startswith(haystack, needle)
return ssub(haystack, 1, #needle) == needle
end
local function ltrim(str)
return smatch(str, "^%s*(.-)$")
end
local function rtrim(str)
return smatch(str, "^(.-)%s*$")
end
-------------------------------------------------------------------------------
-- Implementation.
--
local class = {__meta={}}
function class.__meta.__call(cls, ...)
local self = setmetatable({}, cls)
if cls.__init then
cls.__init(self, ...)
end
return self
end
function class.def(base, typ, cls)
base = base or class
local mt = {__metatable=base, __index=base}
for k, v in pairs(base.__meta) do mt[k] = v end
cls = setmetatable(cls or {}, mt)
cls.__index = cls
cls.__metatable = cls
cls.__type = typ
cls.__meta = mt
return cls
end
local types = {
null = class:def('null'),
map = class:def('map'),
omap = class:def('omap'),
pairs = class:def('pairs'),
set = class:def('set'),
seq = class:def('seq'),
timestamp = class:def('timestamp'),
}
local Null = types.null
function Null.__tostring() return 'yaml.null' end
function Null.isnull(v)
if v == nil then return true end
if type(v) == 'table' and getmetatable(v) == Null then return true end
return false
end
local null = Null()
function types.timestamp:__init(y, m, d, h, i, s, f, z)
self.year = tonumber(y)
self.month = tonumber(m)
self.day = tonumber(d)
self.hour = tonumber(h or 0)
self.minute = tonumber(i or 0)
self.second = tonumber(s or 0)
if type(f) == 'string' and sfind(f, '^%d+$') then
self.fraction = tonumber(f) * math.pow(10, 3 - #f)
elseif f then
self.fraction = f
else
self.fraction = 0
end
self.timezone = z
end
function types.timestamp:__tostring()
return string.format(
'%04d-%02d-%02dT%02d:%02d:%02d.%03d%s',
self.year, self.month, self.day,
self.hour, self.minute, self.second, self.fraction,
self:gettz())
end
function types.timestamp:gettz()
if not self.timezone then
return ''
end
if self.timezone == 0 then
return 'Z'
end
local sign = self.timezone > 0
local z = sign and self.timezone or -self.timezone
local zh = math.floor(z)
local zi = (z - zh) * 60
return string.format(
'%s%02d:%02d', sign and '+' or '-', zh, zi)
end
local function countindent(line)
local _, j = sfind(line, '^%s+')
if not j then
return 0, line
end
return j, ssub(line, j+1)
end
local function parsestring(line, stopper)
stopper = stopper or ''
local q = ssub(line, 1, 1)
if q == ' ' or q == '\t' then
return parsestring(ssub(line, 2))
end
if q == "'" then
local i = sfind(line, "'", 2, true)
if not i then
return nil, line
end
return ssub(line, 2, i-1), ssub(line, i+1)
end
if q == '"' then
local i, buf = 2, ''
while i < #line do
local c = ssub(line, i, i)
if c == '\\' then
local n = ssub(line, i+1, i+1)
if UNESCAPES[n] ~= nil then
buf = buf..UNESCAPES[n]
elseif n == 'x' then
local h = ssub(i+2,i+3)
if sfind(h, '^[0-9a-fA-F]$') then
buf = buf..schar(tonumber(h, 16))
i = i + 2
else
buf = buf..'x'
end
else
buf = buf..n
end
i = i + 1
elseif c == q then
break
else
buf = buf..c
end
i = i + 1
end
return buf, ssub(line, i+1)
end
if q == '{' or q == '[' then -- flow style
return nil, line
end
if q == '|' or q == '>' then -- block
return nil, line
end
if q == '-' or q == ':' then
if ssub(line, 2, 2) == ' ' or #line == 1 then
return nil, line
end
end
local buf = ''
while #line > 0 do
local c = ssub(line, 1, 1)
if sfind(stopper, c, 1, true) then
break
elseif c == ':' and (ssub(line, 2, 2) == ' ' or #line == 1) then
break
elseif c == '#' and (ssub(buf, #buf, #buf) == ' ') then
break
else
buf = buf..c
end
line = ssub(line, 2)
end
return rtrim(buf), line
end
local function isemptyline(line)
return line == '' or sfind(line, '^%s*$') or sfind(line, '^%s*#')
end
local function equalsline(line, needle)
return startswith(line, needle) and isemptyline(ssub(line, #needle+1))
end
local function checkdupekey(map, key)
if map[key] ~= nil then
-- print("found a duplicate key '"..key.."' in line: "..line)
local suffix = 1
while map[key..'_'..suffix] do
suffix = suffix + 1
end
key = key ..'_'..suffix
end
return key
end
local function parseflowstyle(line, lines)
local stack = {}
while true do
if #line == 0 then
if #lines == 0 then
break
else
line = tremove(lines, 1)
end
end
local c = ssub(line, 1, 1)
if c == '#' then
line = ''
elseif c == ' ' or c == '\t' or c == '\r' or c == '\n' then
line = ssub(line, 2)
elseif c == '{' or c == '[' then
tinsert(stack, {v={},t=c})
line = ssub(line, 2)
elseif c == ':' then
local s = tremove(stack)
tinsert(stack, {v=s.v, t=':'})
line = ssub(line, 2)
elseif c == ',' then
local value = tremove(stack)
if value.t == ':' or value.t == '{' or value.t == '[' then error() end
if stack[#stack].t == ':' then
-- map
local key = tremove(stack)
key.v = checkdupekey(stack[#stack].v, key.v)
stack[#stack].v[key.v] = value.v
elseif stack[#stack].t == '{' then
-- set
stack[#stack].v[value.v] = true
elseif stack[#stack].t == '[' then
-- seq
tinsert(stack[#stack].v, value.v)
end
line = ssub(line, 2)
elseif c == '}' then
if stack[#stack].t == '{' then
if #stack == 1 then break end
stack[#stack].t = '}'
line = ssub(line, 2)
else
line = ','..line
end
elseif c == ']' then
if stack[#stack].t == '[' then
if #stack == 1 then break end
stack[#stack].t = ']'
line = ssub(line, 2)
else
line = ','..line
end
else
local s, rest = parsestring(line, ',{}[]')
if not s then
error('invalid flowstyle line: '..line)
end
tinsert(stack, {v=s, t='s'})
line = rest
end
end
return stack[1].v, line
end
local function parseblockstylestring(line, lines, indent)
if #lines == 0 then
error("failed to find multi-line scalar content")
end
local s = {}
local firstindent = -1
local endline = -1
for i = 1, #lines do
local ln = lines[i]
local idt = countindent(ln)
if idt <= indent then
break
end
if ln == '' then
tinsert(s, '')
else
if firstindent == -1 then
firstindent = idt
elseif idt < firstindent then
break
end
tinsert(s, ssub(ln, firstindent + 1))
end
endline = i
end
local striptrailing = true
local sep = '\n'
local newlineatend = true
if line == '|' then
striptrailing = true
sep = '\n'
newlineatend = true
elseif line == '|+' then
striptrailing = false
sep = '\n'
newlineatend = true
elseif line == '|-' then
striptrailing = true
sep = '\n'
newlineatend = false
elseif line == '>' then
striptrailing = true
sep = ' '
newlineatend = true
elseif line == '>+' then
striptrailing = false
sep = ' '
newlineatend = true
elseif line == '>-' then
striptrailing = true
sep = ' '
newlineatend = false
else
error('invalid blockstyle string:'..line)
end
local eonl = 0
for i = #s, 1, -1 do
if s[i] == '' then
tremove(s, i)
eonl = eonl + 1
end
end
if striptrailing then
eonl = 0
end
if newlineatend then
eonl = eonl + 1
end
for i = endline, 1, -1 do
tremove(lines, i)
end
return table.concat(s, sep)..string.rep('\n', eonl)
end
local function parsetimestamp(line)
local _, p1, y, m, d = sfind(line, '^(%d%d%d%d)%-(%d%d)%-(%d%d)')
if not p1 then
return nil, line
end
if p1 == #line then
return types.timestamp(y, m, d), ''
end
local _, p2, h, i, s = sfind(line, '^[Tt ](%d+):(%d+):(%d+)', p1+1)
if not p2 then
return types.timestamp(y, m, d), ssub(line, p1+1)
end
if p2 == #line then
return types.timestamp(y, m, d, h, i, s), ''
end
local _, p3, f = sfind(line, '^%.(%d+)', p2+1)
if not p3 then
p3 = p2
f = 0
end
local zc = ssub(line, p3+1, p3+1)
local _, p4, zs, z = sfind(line, '^ ?([%+%-])(%d+)', p3+1)
if p4 then
z = tonumber(z)
local _, p5, zi = sfind(line, '^:(%d+)', p4+1)
if p5 then
z = z + tonumber(zi) / 60
end
z = zs == '-' and -tonumber(z) or tonumber(z)
elseif zc == 'Z' then
p4 = p3 + 1
z = 0
else
p4 = p3
z = false
end
return types.timestamp(y, m, d, h, i, s, f, z), ssub(line, p4+1)
end
local function parsescalar(line, lines, indent)
line = ltrim(line)
line = gsub(line, '^%s*#.*$', '') -- comment only -> ''
line = gsub(line, '^%s*', '') -- trim head spaces
if line == '' or line == '~' then
return null
end
local ts, _ = parsetimestamp(line)
if ts then
return ts
end
local s, _ = parsestring(line)
-- startswith quote ... string
-- not startswith quote ... maybe string
if s and (startswith(line, '"') or startswith(line, "'")) then
return s
end
if startswith('!', line) then -- unexpected tagchar
error('unsupported line: '..line)
end
if equalsline(line, '{}') then
return {}
end
if equalsline(line, '[]') then
return {}
end
if startswith(line, '{') or startswith(line, '[') then
return parseflowstyle(line, lines)
end
if startswith(line, '|') or startswith(line, '>') then
return parseblockstylestring(line, lines, indent)
end
-- Regular unquoted string
line = gsub(line, '%s*#.*$', '') -- trim tail comment
local v = line
if v == 'null' or v == 'Null' or v == 'NULL'then
return null
elseif v == 'true' or v == 'True' or v == 'TRUE' then
return true
elseif v == 'false' or v == 'False' or v == 'FALSE' then
return false
elseif v == '.inf' or v == '.Inf' or v == '.INF' then
return math.huge
elseif v == '+.inf' or v == '+.Inf' or v == '+.INF' then
return math.huge
elseif v == '-.inf' or v == '-.Inf' or v == '-.INF' then
return -math.huge
elseif v == '.nan' or v == '.NaN' or v == '.NAN' then
return 0 / 0
elseif sfind(v, '^[%+%-]?[0-9]+$') or sfind(v, '^[%+%-]?[0-9]+%.$')then
return tonumber(v) -- : int
elseif sfind(v, '^[%+%-]?[0-9]+%.[0-9]+$') then
return tonumber(v)
end
return s or v
end
local parsemap; -- : func
local function parseseq(line, lines, indent)
local seq = setmetatable({}, types.seq)
if line ~= '' then
error()
end
while #lines > 0 do
-- Check for a new document
line = lines[1]
if startswith(line, '---') then
while #lines > 0 and not startswith(lines, '---') do
tremove(lines, 1)
end
return seq
end
-- Check the indent level
local level = countindent(line)
if level < indent then
return seq
elseif level > indent then
error("found bad indenting in line: ".. line)
end
local i, j = sfind(line, '%-%s+')
if not i then
i, j = sfind(line, '%-$')
if not i then
return seq
end
end
local rest = ssub(line, j+1)
if sfind(rest, '^[^\'\"%s]*:') then
-- Inline nested hash
local indent2 = j
lines[1] = string.rep(' ', indent2)..rest
tinsert(seq, parsemap('', lines, indent2))
elseif sfind(rest, '^%-%s+') then
-- Inline nested seq
local indent2 = j
lines[1] = string.rep(' ', indent2)..rest
tinsert(seq, parseseq('', lines, indent2))
elseif isemptyline(rest) then
tremove(lines, 1)
if #lines == 0 then
tinsert(seq, null)
return seq
end
if sfind(lines[1], '^%s*%-') then
local nextline = lines[1]
local indent2 = countindent(nextline)
if indent2 == indent then
-- Null seqay entry
tinsert(seq, null)
else
tinsert(seq, parseseq('', lines, indent2))
end
else
-- - # comment
-- key: value
local nextline = lines[1]
local indent2 = countindent(nextline)
tinsert(seq, parsemap('', lines, indent2))
end
elseif rest then
-- Array entry with a value
tremove(lines, 1)
tinsert(seq, parsescalar(rest, lines))
end
end
return seq
end
local function parseset(line, lines, indent)
if not isemptyline(line) then
error('not seq line: '..line)
end
local set = setmetatable({}, types.set)
while #lines > 0 do
-- Check for a new document
line = lines[1]
if startswith(line, '---') then
while #lines > 0 and not startswith(lines, '---') do
tremove(lines, 1)
end
return set
end
-- Check the indent level
local level = countindent(line)
if level < indent then
return set
elseif level > indent then
error("found bad indenting in line: ".. line)
end
local i, j = sfind(line, '%?%s+')
if not i then
i, j = sfind(line, '%?$')
if not i then
return set
end
end
local rest = ssub(line, j+1)
if sfind(rest, '^[^\'\"%s]*:') then
-- Inline nested hash
local indent2 = j
lines[1] = string.rep(' ', indent2)..rest
set[parsemap('', lines, indent2)] = true
elseif sfind(rest, '^%s+$') then
tremove(lines, 1)
if #lines == 0 then
tinsert(set, null)
return set
end
if sfind(lines[1], '^%s*%?') then
local indent2 = countindent(lines[1])
if indent2 == indent then
-- Null array entry
set[null] = true
else
set[parseseq('', lines, indent2)] = true
end
end
elseif rest then
tremove(lines, 1)
set[parsescalar(rest, lines)] = true
else
error("failed to classify line: "..line)
end
end
return set
end
function parsemap(line, lines, indent)
if not isemptyline(line) then
error('not map line: '..line)
end
local map = setmetatable({}, types.map)
while #lines > 0 do
-- Check for a new document
line = lines[1]
if startswith(line, '---') then
while #lines > 0 and not startswith(lines, '---') do
tremove(lines, 1)
end
return map
end
-- Check the indent level
local level, _ = countindent(line)
if level < indent then
return map
elseif level > indent then
error("found bad indenting in line: ".. line)
end
-- Find the key
local key
local s, rest = parsestring(line)
-- Quoted keys
if s and startswith(rest, ':') then
local sc = parsescalar(s, {}, 0)
if sc and type(sc) ~= 'string' then
key = sc
else
key = s
end
line = ssub(rest, 2)
else
error("failed to classify line: "..line)
end
key = checkdupekey(map, key)
line = ltrim(line)
if ssub(line, 1, 1) == '!' then
-- ignore type
local rh = ltrim(ssub(line, 3))
local typename = smatch(rh, '^!?[^%s]+')
line = ltrim(ssub(rh, #typename+1))
end
if not isemptyline(line) then
tremove(lines, 1)
line = ltrim(line)
map[key] = parsescalar(line, lines, indent)
else
-- An indent
tremove(lines, 1)
if #lines == 0 then
map[key] = null
return map;
end
if sfind(lines[1], '^%s*%-') then
local indent2 = countindent(lines[1])
map[key] = parseseq('', lines, indent2)
elseif sfind(lines[1], '^%s*%?') then
local indent2 = countindent(lines[1])
map[key] = parseset('', lines, indent2)
else
local indent2 = countindent(lines[1])
if indent >= indent2 then
-- Null hash entry
map[key] = null
else
map[key] = parsemap('', lines, indent2)
end
end
end
end
return map
end
-- : (list<str>)->dict
local function parsedocuments(lines)
lines = select(lines, function(s) return not isemptyline(s) end)
if sfind(lines[1], '^%%YAML') then tremove(lines, 1) end
local root = {}
local in_document = false
while #lines > 0 do
local line = lines[1]
-- Do we have a document header?
local docright;
if sfind(line, '^%-%-%-') then
-- Handle scalar documents
docright = ssub(line, 4)
tremove(lines, 1)
in_document = true
end
if docright then
if (not sfind(docright, '^%s+$') and
not sfind(docright, '^%s+#')) then
tinsert(root, parsescalar(docright, lines))
end
elseif #lines == 0 or startswith(line, '---') then
-- A naked document
tinsert(root, null)
while #lines > 0 and not sfind(lines[1], '---') do
tremove(lines, 1)
end
in_document = false
-- XXX The final '-+$' is to look for -- which ends up being an
-- error later.
elseif not in_document and #root > 0 then
-- only the first document can be explicit
error('parse error: '..line)
elseif sfind(line, '^%s*%-') then
-- An array at the root
tinsert(root, parseseq('', lines, 0))
elseif sfind(line, '^%s*[^%s]') then
-- A hash at the root
local level = countindent(line)
tinsert(root, parsemap('', lines, level))
else
-- Shouldn't get here. @lines have whitespace-only lines
-- stripped, and previous match is a line with any
-- non-whitespace. So this clause should only be reachable via
-- a perlbug where \s is not symmetric with \S
-- uncoverable statement
error('parse error: '..line)
end
end
if #root > 1 and Null.isnull(root[1]) then
tremove(root, 1)
return root
end
return root
end
--- Parse yaml string into table.
local function parse(source)
local lines = {}
for line in string.gmatch(source .. '\n', '(.-)\r?\n') do
tinsert(lines, line)
end
local docs = parsedocuments(lines)
if #docs == 1 then
return docs[1]
end
return docs
end
return {
version = 0.1,
parse = parse,
}