local preload = type(package) == "table" and type(package.preload) == "table" and package.preload or {}
local require = require
if type(require) ~= "function" then
local loading = {}
local loaded = {}
require = function(name)
local result = loaded[name]
if result ~= nil then
if result == loading then
error("loop or previous error loading module '" .. name .. "'", 2)
end
return result
end
loaded[name] = loading
local contents = preload[name]
if contents then
result = contents(name)
else
error("cannot load '" .. name .. "'", 2)
end
if result == nil then result = true end
loaded[name] = result
return result
end
end
preload["util.sound"] = function(...)
local function playSound(speaker, sound)
if not speaker then
return
end
speaker.playSound(sound.name, sound.volume, sound.pitch)
end
return {
playSound = playSound
}
end
preload["util.setPalette"] = function(...)
require("modules.rif") -- Initialize colors
return function(t, palette)
for k, v in pairs(palette) do
t.setPaletteColor(k, v)
end
end
end
preload["util.score"] = function(...)
local score = {}
---Applies {fun} to each element of {list} and returns a list of the results.
---@generic T, U
---@param list T[]
---@param fun fun(x: T, i: integer): U
---@return U[]
function score.map(list, fun)
local result = {__type = "list"}
for i, v in ipairs(list) do
result[i] = fun(v, i)
end
return result
end
function score.range(start, stop, step)
local result = {__type = "list"}
if step == nil then
step = 1
end
if stop == nil then
stop = start
start = 1
end
for i = start, stop, step do
result[#result + 1] = i
end
return result
end
function score.rangeMap(start, stop, step, fun)
if type(start) == "function" then
fun = start
start = 1
stop = nil
step = nil
elseif type(stop) == "function" then
fun = stop
stop = start
start = 1
step = nil
elseif type(step) == "function" then
fun = step
step = 1
end
return score.map(score.range(start, stop, step), fun)
end
---Returns a list of the elements from {list} that satisfy the predicate {fun}.
---@generic T
---@param list T[]
---@param fun fun(x: T): boolean
---@return T[]
function score.filter(list, fun)
local result = {__type = "list"}
for i, v in ipairs(list) do
if fun(v) then
result[#result + 1] = v
end
end
return result
end
function score.intersectSeq(list1, list2)
local result = {__type = "list"}
if #list1 > #list2 then
list1, list2 = list2, list1
end
for i = #list1 + 1, #list2 do
result[#result + 1] = list2[i]
end
return result
end
function score.flat(list)
local result = {__type = "list"}
for i, v in ipairs(list) do
if v.__type == "list" then
for j, w in ipairs(v) do
result[#result + 1] = w
end
else
result[#result + 1] = v
end
end
return result
end
---Fisher yates shuffle
---@generic T
---@param list T[]
---@return T[]
function score.shuffle(list)
local n = #list
while n > 2 do
local k = math.random(n)
list[n], list[k] = list[k], list[n]
n = n - 1
end
return list
end
function score.append(list, value)
list = {unpack(list)}
list[#list + 1] = value
return list
end
---Deeply copies a table, except for tables that have a __opaque property.
---@generic T: table
---@param t T
---@return T
function score.copyDeep(t)
if type(t) ~= "table" then
return t
end
local copy = {}
for k, v in pairs(t) do
if type(v) == "table" and v.__opaque == nil and v.__nocopy == nil then
copy[k] = score.copyDeep(v)
else
copy[k] = v
end
end
return copy
end
---@generic T
---@param list T[]
---@return T[]
function score.filterTruthy(list)
local result = {__type = "list"}
for i, v in ipairs(list) do
if v then
result[#result + 1] = v
end
end
return result
end
return score
end
preload["util.renderHelpers"] = function(...)
local bigFont = require("fonts.bigfont")
local smolFont = require("fonts.smolfont")
local Pricing = require("core.Pricing")
local function getDisplayedProducts(allProducts, settings, currency)
local displayedProducts = {}
for i = 1, #allProducts do
local product = allProducts[i]
product.id = i
local productPrice = Pricing.getProductPrice(product, currency)
if
(not settings.hideUnavailableProducts or (product.quantity and product.quantity > 0))
and (not settings.hideNegativePrices or (productPrice and productPrice >= 0))
then
table.insert(displayedProducts, product)
end
end
return displayedProducts
end
local function getCurrencySymbol(currency, layout)
if currency.krypton and currency.krypton.currency then
currencySymbol = currency.krypton.currency.currency_symbol
elseif not currencySymbol and currency.name and currency.name:find("%.") then
currencySymbol = currency.name:sub(currency.name:find("%.")+1, #currency.name)
elseif currency.id == "tenebra" then
currencySymbol = "tst"
else
currencySymbol = "KST"
end
if currencySymbol == "TST" then
currencySymbol = "tst"
end
if currencySymbol:lower() == "kst" and (layout == "medium" or layout == "small") then
currencySymbol = "kst"
elseif currencySymbol:lower() == "kst" then
currencySymbol = "\164"
end
return currencySymbol
end
local function getCategories(products)
local categories = {}
for _, product in ipairs(products) do
if not product.hidden then
local category = product.category
if not category then
category = "*"
end
local found = nil
for i = 1, #categories do
if categories[i].name == category then
found = i
break
end
end
if not found then
if category == "*" then
table.insert(categories, 1, {name=category, products={}})
found = 1
else
table.insert(categories, {name=category, products={}})
found = #categories
end
end
table.insert(categories[found].products, product)
end
end
return categories
end
local function getWidth(text, fontSize)
if fontSize == "large" then
return bigFont:getWidth(text)
elseif fontSize == "medium" then
return smolFont:getWidth(text)
else
return #text
end
end
local function getThemeSetting(theme, path, layout)
-- Split on "."
layoutTheme = {}
if theme.layouts and theme.layouts[layout] then
layoutTheme = theme.layouts[layout]
end
local paths = path:gmatch("[^%.]+")
for subpath in paths do
if layoutTheme and layoutTheme[subpath] then
layoutTheme = layoutTheme[subpath]
else
layoutTheme = nil
end
if theme and theme[subpath] then
theme = theme[subpath]
else
theme = nil
end
if not theme and not layoutTheme then
return nil
end
end
if layoutTheme then
return layoutTheme
else
return theme
end
end
return {
getDisplayedProducts = getDisplayedProducts,
getCurrencySymbol = getCurrencySymbol,
getCategories = getCategories,
getWidth = getWidth,
getThemeSetting = getThemeSetting,
}
end
preload["util.misc"] = function(...)
local _ = require("util.score")
local Solyd = require("modules.solyd")
local Canvases = require("modules.canvas")
local PixelCanvas = Canvases.PixelCanvas
local function tableSize(t)
if type(t) ~= "table" then
return nil
end
local count = 0
for k, v in pairs(t) do
count = count + 1
end
return count
end
---Renders a Solyd tree and bakes the canvases into one
function bakeToCanvas(rootComponent)
local tree = Solyd.render(nil, rootComponent)
local context = Solyd.getTopologicalContext(tree, { "canvas" })
local minX, minY = math.huge, math.huge
local maxX, maxY = -math.huge, -math.huge
for _, canvas in ipairs(context.canvas) do
minX = math.min(minX, canvas[2])
minY = math.min(minY, canvas[3])
maxX = math.max(maxX, canvas[2] + canvas[1].width - 1)
maxY = math.max(maxY, canvas[3] + canvas[1].height - 1)
end
local canvas = PixelCanvas.new(maxX - minX + 1, maxY - minY + 1)
canvas:composite(_.map(context.canvas, function(c)
return {c[1], c[2] - minX + 1, c[3] - minY + 1}
end))
return canvas
end
--Deeply copies a table, not taking into account __opaque and __nocopy
local function plainDeepCopy(t)
if type(t) ~= "table" then
return t
end
local copy = {}
for k, v in pairs(t) do
if type(v) == "table" then
copy[k] = plainDeepCopy(v)
else
copy[k] = v
end
end
return copy
end
return {
tableSize = tableSize,
bakeToCanvas = bakeToCanvas,
plainDeepCopy = plainDeepCopy
}
end
preload["util.iter"] = function(...)
-- Iterator for list
local function list(xs)
local i = 0
return function()
i = i + 1
return xs[i]
end
end
return {
list = list,
}
end
preload["util.eventHook"] = function(...)
local function executeHook(func, ...)
local args = {...}
local ret = {pcall(func, unpack(args))}
if not ret[1] then
print("Error in hook: " .. ret[2])
return
end
table.remove(ret, 1)
return unpack(ret)
end
return {
execute = executeHook
}
end
preload["util.configHelpers"] = function(...)
local score = require("util.score")
function getPeripherals(config, peripherals)
local modem
local failed = 0
repeat
if config.peripherals.modem then
modem = peripheral.wrap(config.peripherals.modem)
else
modem = peripheral.find("modem", function(pName)
return not peripheral.wrap(pName).isWireless()
end)
if not modem then
error("No modem found")
end
if not modem.getNameLocal() then
error("Modem is not connected! Turn it on by right clicking it!")
end
end
if not modem then
failed = failed + 1
sleep(2)
end
until modem or failed > 2
if not modem then
error("No modem found")
end
local shopSyncModem
if config.peripherals.shopSyncModem then
shopSyncModem = peripheral.wrap(config.peripherals.shopSyncModem)
else
shopSyncModem = peripheral.find("modem", function(pName)
return peripheral.wrap(pName).isWireless()
end)
if not shopSyncModem and config.shopSync and config.shopSync.enabled then
error("No wireless modem found but ShopSync is enabled!")
end
end
local speaker
if config.peripherals.speaker then
speaker = peripheral.wrap(config.peripherals.speaker)
else
speaker = peripheral.find("speaker")
end
if modem then peripherals.modem = modem end
if shopSyncModem then peripherals.shopSyncModem = shopSyncModem end
if speaker then peripherals.speaker = speaker end
return peripherals
end
local colorNames = {
[colors.black] = "colors.black",
[colors.blue] = "colors.blue",
[colors.purple] = "colors.purple",
[colors.green] = "colors.green",
[colors.brown] = "colors.brown",
[colors.gray] = "colors.gray",
[colors.lightGray] = "colors.lightGray",
[colors.red] = "colors.red",
[colors.orange] = "colors.orange",
[colors.yellow] = "colors.yellow",
[colors.lime] = "colors.lime",
[colors.cyan] = "colors.cyan",
[colors.magenta] = "colors.magenta",
[colors.pink] = "colors.pink",
[colors.lightBlue] = "colors.lightBlue",
[colors.white] = "colors.white",
}
function getColorName(num)
return colorNames[num]
end
function getNewConfig(config, configDiffs, arrayAdds, arrayRemoves)
local newConfig = score.copyDeep(config)
for k, addSchema in pairs(arrayAdds) do
local subConfig = newConfig
local fullPath = nil
for path in k:gmatch("([^%[?%]?%.?]+)") do
if fullPath then
fullPath = fullPath .. "." .. path
else
fullPath = path
end
if path:match("%d+") then
path = tonumber(path) or path
end
if subConfig[path] then
subConfig = subConfig[path]
else
if fullPath == k and type(addSchema) == "string" and addSchema:sub(1,6) == "number" then
subConfig[path] = 0
else
subConfig[path] = {}
end
subConfig = subConfig[path]
end
end
end
for k, v in pairs(configDiffs) do
local subConfig = newConfig
for path in k:gmatch("([^%[?%]?%.?]+)%.+") do
if path:match("%d+") then
path = tonumber(path) or path
end
if subConfig[path] then
subConfig = subConfig[path]
else
subConfig[path] = {}
subConfig = subConfig[path]
end
end
local lastPath = k:match("^.+%.([^%.]+)$")
if lastPath:match("%d+") then
lastPath = tonumber(lastPath) or lastPath
end
if v == "%nil%" then
v = nil
end
subConfig[lastPath] = v
end
local removalArrays = {}
for k, _ in pairs(arrayRemoves) do
local subConfig = newConfig
for path in k:gmatch("([^%[?%]?%.?]+)%.+") do
if path:match("%d+") then
path = tonumber(path) or path
end
if subConfig[path] then
subConfig = subConfig[path]
else
subConfig[path] = {}
subConfig = subConfig[path]
end
end
local lastPath, index = ("." .. k):match("^.*%.([^%.]+)%.([^%.]+)$")
if not lastPath then
lastPath = ""
end
if not index then
index = k:match("^.*%.([^%.]+)$")
end
if lastPath:match("%d+") then
lastPath = tonumber(lastPath) or lastPath
end
if index:match("%d+") then
index = tonumber(index) or index
end
local array = subConfig
-- if lastPath ~= "" then
-- array = subConfig[lastPath]
-- else
-- array = subConfig
-- end
if not removalArrays[lastPath] then
removalArrays[lastPath] = { array = array, indices = {} }
end
table.insert(removalArrays[lastPath].indices, index)
end
for k, v in pairs(removalArrays) do
table.sort(v.indices, function(a, b) return a > b end)
for _, index in ipairs(v.indices) do
table.remove(v.array, index)
end
end
if newConfig.currencies then
for k,v in pairs(newConfig.currencies) do
newConfig.currencies[k].krypton = nil
end
end
newConfig.hooks = nil
return newConfig
end
function loadDefaults(config, defaults)
for k, v in pairs(defaults) do
if type(v) == "table" then
if v[1] then -- Is a table, only replace if not exists
if not config[k] then
config[k] = {}
loadDefaults(config[k], v)
end
else
if not config[k] then
config[k] = {}
end
loadDefaults(config[k], v)
end
else
if config[k] == nil then
config[k] = v
end
end
end
end
return {
getPeripherals = getPeripherals,
getColorName = getColorName,
getNewConfig = getNewConfig,
loadDefaults = loadDefaults,
}
end
preload["util.base64"] = function(...)
--[[
base64 -- v1.5.3 public domain Lua base64 encoder/decoder
no warranty implied; use at your own risk
Needs bit32.extract function. If not present it's implemented using BitOp
or Lua 5.3 native bit operators. For Lua 5.1 fallbacks to pure Lua
implementation inspired by Rici Lake's post:
http://ricilake.blogspot.co.uk/2007/10/iterating-bits-in-lua.html
author: Ilya Kolbin (
[email protected])
url: github.com/iskolbin/lbase64
COMPATIBILITY
Lua 5.1+, LuaJIT
LICENSE
See end of file for license information.
--]]
local base64 = {}
local extract = _G.bit32 and _G.bit32.extract -- Lua 5.2/Lua 5.3 in compatibility mode
if not extract then
if _G.bit then -- LuaJIT
local shl, shr, band = _G.bit.lshift, _G.bit.rshift, _G.bit.band
extract = function( v, from, width )
return band( shr( v, from ), shl( 1, width ) - 1 )
end
elseif _G._VERSION == "Lua 5.1" then
extract = function( v, from, width )
local w = 0
local flag = 2^from
for i = 0, width-1 do
local flag2 = flag + flag
if v % flag2 >= flag then
w = w + 2^i
end
flag = flag2
end
return w
end
else -- Lua 5.3+
extract = load[[return function( v, from, width )
return ( v >> from ) & ((1 << width) - 1)
end]]()
end
end
function base64.makeencoder( s62, s63, spad )
local encoder = {}
for b64code, char in pairs{[0]='A','B','C','D','E','F','G','H','I','J',
'K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y',
'Z','a','b','c','d','e','f','g','h','i','j','k','l','m','n',
'o','p','q','r','s','t','u','v','w','x','y','z','0','1','2',
'3','4','5','6','7','8','9',s62 or '+',s63 or'/',spad or'='} do
encoder[b64code] = char:byte()
end
return encoder
end
function base64.makedecoder( s62, s63, spad )
local decoder = {}
for b64code, charcode in pairs( base64.makeencoder( s62, s63, spad )) do
decoder[charcode] = b64code
end
return decoder
end
local DEFAULT_ENCODER = base64.makeencoder()
local DEFAULT_DECODER = base64.makedecoder()
local char, concat = string.char, table.concat
function base64.encode( str, encoder, usecaching )
encoder = encoder or DEFAULT_ENCODER
local t, k, n = {}, 1, #str
local lastn = n % 3
local cache = {}
for i = 1, n-lastn, 3 do
if i % 900 == 0 then
os.queueEvent("yieldb64")
os.pullEvent("yieldb64")
end
local a, b, c = str:byte( i, i+2 )
local v = a*0x10000 + b*0x100 + c
local s
if usecaching then
s = cache[v]
if not s then
s = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[extract(v,0,6)])
cache[v] = s
end
else
s = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[extract(v,0,6)])
end
t[k] = s
k = k + 1
end
if lastn == 2 then
local a, b = str:byte( n-1, n )
local v = a*0x10000 + b*0x100
t[k] = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[64])
elseif lastn == 1 then
local v = str:byte( n )*0x10000
t[k] = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[64], encoder[64])
end
return concat( t )
end
function base64.decode( b64, decoder, usecaching )
decoder = decoder or DEFAULT_DECODER
local pattern = '[^%w%+%/%=]'
if decoder then
local s62, s63
for charcode, b64code in pairs( decoder ) do
if b64code == 62 then s62 = charcode
elseif b64code == 63 then s63 = charcode
end
end
pattern = ('[^%%w%%%s%%%s%%=]'):format( char(s62), char(s63) )
end
b64 = b64:gsub( pattern, '' )
local cache = usecaching and {}
local t, k = {}, 1
local n = #b64
local padding = b64:sub(-2) == '==' and 2 or b64:sub(-1) == '=' and 1 or 0
for i = 1, padding > 0 and n-4 or n, 4 do
local a, b, c, d = b64:byte( i, i+3 )
local s
if usecaching then
local v0 = a*0x1000000 + b*0x10000 + c*0x100 + d
s = cache[v0]
if not s then
local v = decoder[a]*0x40000 + decoder[b]*0x1000 + decoder[c]*0x40 + decoder[d]
s = char( extract(v,16,8), extract(v,8,8), extract(v,0,8))
cache[v0] = s
end
else
local v = decoder[a]*0x40000 + decoder[b]*0x1000 + decoder[c]*0x40 + decoder[d]
s = char( extract(v,16,8), extract(v,8,8), extract(v,0,8))
end
t[k] = s
k = k + 1
end
if padding == 1 then
local a, b, c = b64:byte( n-3, n-1 )
local v = decoder[a]*0x40000 + decoder[b]*0x1000 + decoder[c]*0x40
t[k] = char( extract(v,16,8), extract(v,8,8))
elseif padding == 2 then
local a, b = b64:byte( n-3, n-2 )
local v = decoder[a]*0x40000 + decoder[b]*0x1000
t[k] = char( extract(v,16,8))
end
return concat( t )
end
return base64
--[[
------------------------------------------------------------------------------
This software is available under 2 licenses -- choose whichever you prefer.
------------------------------------------------------------------------------
ALTERNATIVE A - MIT License
Copyright (c) 2018 Ilya Kolbin
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.
------------------------------------------------------------------------------
ALTERNATIVE B - Public Domain (www.unlicense.org)
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
software, either in source code form or as a compiled binary, for any purpose,
commercial or non-commercial, and by any means.
In jurisdictions that recognize copyright laws, the author or authors of this
software dedicate any and all copyright interest in the software to the public
domain. We make this dedication for the benefit of the public at large and to
the detriment of our heirs and successors. We intend this dedication to be an
overt act of relinquishment in perpetuity of all present and future rights to
this software under copyright law.
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 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.
------------------------------------------------------------------------------
--]]
end
preload["res.smolfont"] = function(...)
return "UklWAgGkAAcBAAAAAAAAAAAAAAAAcAAAAABwAAAAcAAAcAAHAABwcAB3AAAAAAAAAAAAAAAAAAAABwAAAAAAAAAAAAAAAAAHAHcAB3B3AHdwd3AHcHBwd3AAcHBwcABwcHBwBwB3AAcAdwAHcHdwcHBwcHBwcHBwcHdwB3AHAHcAdwBwcHdwB3B3cHdwd3AAAAAABwAABwAHdwd3AAAHdwBwAAAAdwBwB3AAdwd3BwAAAAcAcHBwcAdwcAB3AAcAAHBwAHBwAAAAAAAAAAAAcAd3AAcAcAAAAAAAAAAAAAAAAAAAAHcAdwAHcAdwB3AHAAdwdwAAAAAAcHAHAHdwdwAHAHcAB3AHcAdwd3BwcHBwcHBwcHBwd3BwcHBwcABwcHAAcABwAHBwBwAAcHBwcAB3cHdwcHBwcHBwcHBwAAcAcHBwcHBwcHBwcABwcHB3AABwAHBwcHAAcAAAcHBwcHBwAHAAcAd3AHAABwcABwAABwcHAAAAcABwAHAHcAd3AHAAAAcAcHB3cHcAAHB3AAcABwAHAAcABwAAAAAAAAAAcHAAcHd3cAAAAAAAAAAAAAAAAAAAAAdwcHBwAHBwcHB3cHBwcHAHAABwdwAHAHdwcHBwcHBwcHBwAHcABwBwcHBwd3AHAHBwB3B3cHcAcABwcHdwd3B3cHdwBwAAcHcAcAB3cHdwcHB3AHBwd3AHAAcAcHBwcHdwBwAHAAcAcHAHAAcABwB3cHcAd3AHAHdwd3AAAAAHAAAAAAcAcAcAAHAABwAAAAAHAAAAAAcAAAd3AAAAAAcAAABwcAdwBwB3cAAABwAHAHBwd3AAAHdwAAAHAHBwcAdwAAAAAAAAAAAAAAAAAAAAAHBwcHBwAHBwdwAHAHdwcHAHAABwdwAHAHdwcHBwcHBwcHBwAAdwBwBwcHdwd3AHAAdwdwBwcHBwcABwcHAAcABwcHBwBwBwcHBwcABwcHdwcHBwAHdwdwAAcAcAcHAHAHdwcHAHAHAAcHAHAHAAAHAAcABwcHBwAHBwAHBwAHAAcAd3AHAAAAcAAAcABwAAAAAAcABwAHAAAAd3AAAAAAAAAAB3cHcAcABwcAAABwAHAAAABwAHAAAAAABwAHB3cHd3cAAAAAAAAAAAAAAAAAAAAHdwdwAHcAdwB3AHAABwcHAHAHBwcHB3cHBwcHAHAHcAB3BwAHcAB3AHcAcAd3BwcABwd3BwcHcAB3B3AHdwcAAHcHBwd3AHAHBwd3BwcHBwBwBwAAdwcHB3AAcAB3AHAHBwcHAHAHdwdwAHAHdwdwAAcHcAd3BwAHdwdwAABwAABwAABwAAcAd3AAAHdwAAB3cAdwBwB3AAAAd3AAAAAAcAAABwcAcAAHAHcAAAAHBwAAAAAABwAAAABwBwAHAAAAcHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcAAAAAAAcAAAAAAAAAAAAAAHAAAHAAAAAAAAAAAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAd3cAcAcAAAAAAAAAAAAAAAAAAAAAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAABAAABAAAAAAAAAAAAAAAAAAAAO+/v++9zv///9/////NyYipuOqqzc2JqqqK2cyKiYj/fUd87M/mRH+rpnOv6///xu3////PnJmd/K+N3JyZqKqqqOrq7tqrjqiq6q2qqqu8q+6r6m7sdV/97mbstyIvd3f3/68L/v///6muiqq93Kiq6tyq2JrIroiIveyIyordqtjd2t3I2Ih//23v/fffx3+/ZuN3Kz5+q/n///+v6sqN2suNqqqerYidrOrqrtqqrqiOvK2N2q7tu6uu627s993/7n78/yO7fvd3/7sI/v///8iZ2autiqrN6ZzZqIvKyeip2Iqq7anc2aqN3MjL6Mj3fW98fMzmx3+/9uav//u3+/X////////f//3//77////f/////////////////////////////////////////////3/Y/v///3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3e7u7u7u7u7u93d3d3d3d3dfd////8P"
end
preload["res.font"] = function(...)
return "UklWAgA7AAYBAAAAAAAAAAAAAAAADwAPAP8A/wDw8P/wD/D/8A8ADwAPAP/wDwDw8AAPDw/wAA8ADw8PDwAPAAAPDw8PDw8PAPAPDw8PAADw8A8ADwAPAA/w/wD/8ADwDwAP8P/wDwDw8P8AAA8PAPAPAAAPAA8ADw8PAPAPDwAPDw8A8A/wDw8AAA8A//AP8P8AAPD/AP8ADwAPAADw8PD/AA/w8PAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
end
preload["res.cfont"] = function(...)
return "UklWAgH0AAoBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcAAAAAAAAABwAAAAAHcAAAAHAAAHAABwcAAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAB3cAd3cAB3cAd3cAd3dwd3dwB3dwcABwd3AABwcABwcAAHAAcHAAcAd3AHdwAHdwB3dwAHd3B3d3BwAHBwAHBwAHBwAHBwAHB3d3AHdwAAcAAHdwAHdwAAB3B3d3AAdwB3d3AHdwAHdwAAAHAAAAAAAAAABwBwBwAAAAAHAAAHd3cAAHAABwcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAcAAAAAcAAAAABwAAAAAAAHAABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAAAAAAAAAAAHAAcHAAcHAAcHAAcHAAAHAAAHAAAHAAcAcAAAcHAHAHAAB3B3B3AHBwAHBwBwcABwcABwcAAAAHAAcABwcABwcABwBwcABwcAAABwcABwB3AAcABwcABwAHBwcAAABwAAcABwcABwcABwAAcAAAAAAAAAAAcHAAcABwAAd3AAdwAHcABwAHAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHdwBwdwAHdwAHcHAHdwB3dwB3dwcHcAcAAHBwBwcAdwcAd3cAB3cAcHcAB3BwcHcAB3dwd3BwAHBwAHBwAHBwAHBwAHB3d3B3d3B3dwBwAABwAHB3cAB3cABwB3B3d3AHAAAHB3cABwAAcHBwcHBwcABwd3AHAAcHd3AAd3AABwAHAAcHAAcHAAcABwAABwAAAHAHAHcABwAAAAcAAAcAcAcHd3AHAAAAAAcHAAcHAAcAAHAAAAAAAAAAB3d3AHAAAAB3d3AHAHcHAHAAcAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwdwBwcABwcAdwcABwBwAHAAcHcAcHAABwcHAHAHBwcHAAcHAAcHcAcHAHcHcAcHAAAAcAcABwcABwcABwBwcAcABwAAcAcABwcABwcAAAcABwcAAAcAAAcABwcABwBwAABwcAcAcAAHAAcHAHcHAAcHAABwAHBwAHAAAHAAcABwAHBwAHBwAHAHBwAAcAAAcABwcHAAcAAAdwAAdwBwAHAAAHB3dwAABwAHdwAHd3AAcAB3d3AAAAAAB3AABwAAAHd3d3BwcHBwAABwAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAd3cHAAcHAAAHAAcHd3cAcABwAHBwAHBwAAcHcABwBwcHBwAHBwAHBwAHBwAHBwAAAHdwAHAHAAcHAAcHBwcABwAHAAcABwAHAAcHAAcHAAAHAAcHAAAHAAAHAAcHAAcAcAAAcHAAcHAABwAHBwAHBwAHBwAAcABwcABwAABwAHAAcABwBwcAcHBwcABwAHAABwAAdwBwAHAABwAAAABwd3dwAABwcABwAHAAcABwAABwBwAAAAAAAAAAAHd3cAcAAAB3BwdwcHd3cAAABwAABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHBwAHBwAHBwAHBwAAAHAAB3dwcABwcHAHBwcAcAcABwcABwcABwd3cAB3dwcAAAAABwBwBwAHAHBwBwcHAHBwAHd3AHAABwAHBwAHBwAHBwAHBwAABwAABwAHBwAHAHAHAHBwAHBwAAcABwcABwcABwcAAHAHAHAAcHAAcABwAHAAcAcHAHcHcHAAcABwAHAAAHAAcABwAHAAcHAAcAAAcHAAcHAAcABwAHAAcAAHAAcAAAAAAAAAAAAHBwAAAAcAAAcAAHcAAAAAAABwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB3dwd3cAB3cAB3dwB3dwBwAAAAcHAAcHBwBwcAcAcHAAcHAAcAd3AHAAAAAAcHAAAHd3AABwB3dwAHAAB3dwcABwAABwd3dwcABwd3cAB3cAd3cAd3dwcAAAB3cAcABwd3AHcAcABwd3cHAAcHAAcAd3AHAAAHcHBwAHAHdwAAcAAHdwAAcABwAHBwAHAAcAB3d3AHdwB3d3B3d3AHdwAAAHAHdwAHdwAAcAAHdwAHcABwAAAAAAAHAAcABwBwBwAAAAB3cAAHd3cAAAAABwcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB3dwAAAAAAB3AAAAAAAAAAAAAAAAAABwAAAAAHAAAAAAAAAAAAAAAAAAAAAAAAAAAHd3AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAQAAAQAAAQAAAQAAAQAAEAAAEAAAEBAAAQAAEAEAAAEAAAEAAAEAAAEAAAEAAAEAAAEAAQAAAQAAAQAAAQAAAQAAAQAAAQAAAQAAAQAAAQAAAQAAAQAAAQAAAQAAAQABAAAQAAAQAAEAAAEAAAEAAAEAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAEAAQAAAQABAAEAAAABAAAAAQABAAAQAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////9//3//+fte7///////+/////9jOIZBMHTxut51YxzDIei6ruuC8R7HJziDcfz+/23/ffBe///////////////3//fvv//3/v////+//////991Xdf3fbev7UnWta7r767r1vXr5q67+r3ruvf/X3ePc+7u/////////////2M5TmMYyl5rGo7lVA7RdV3XBUHYd3HcBPviXlVd7MLx7rruvt/m/r4t7L+ue///YH+waff9/////////////2Vd0+0u69VWXZc1Wb9d163d23V91/ddt6/tXdP1rvvuruvW+67u8+y+8B2HO/if9wOqvr//////////////Q9d3we66Xm7VdV3Xj9t11d3dXdd3fd91+7redV3vuu/u1qq79+zev+Dr7u7b//9gP6Xg9/3////////////fdV3X76Gr1XZdFw79t91arcPedV3X9123retd1/W26+5uTbq77+6u+7ru7t79/19/3/P/7v////////////9DOA7D/rpaW9eN/deHN9xD9wVdOIZBP3Yxu9B1Y5+68R7vrruDMQjGbxzvce7/3W3/ePBf////////////////////8H/+///ff///////4f/////////////////////////////////////f/////////////////////77v+773fb237/u+7/vu+77v+77v+77v++597/u+933f933f933f933f933f9313393f3733////////////"
end
preload["radon"] = function(...)
local version = "1.3.34-katze"
local configHelpers = require "util.configHelpers"
local schemas = require "core.schemas"
local ScanInventory = require("core.inventory.ScanInventory")
local oldPullEvent = os.pullEvent
os.pullEvent = os.pullEventRaw
local oldPrint = print
local logs = {}
local maxLogs = 100
function print(...)
local args = {...}
local str = ""
for i = 1, #args do
str = str .. tostring(args[i])
if i ~= #args then
str = str .. " "
end
end
table.insert(logs, 1, {time = os.time("utc"), text = str})
if #logs > maxLogs then
table.remove(logs, #logs)
end
end
--- Imports
local _ = require("util.score")
local misc = require("util.misc")
local sound = require("util.sound")
local eventHook = require("util.eventHook")
local Display = require("modules.display")
local Solyd = require("modules.solyd")
local hooks = require("modules.hooks")
local useCanvas = hooks.useCanvas
local Button = require("components.Button")
local SmolButton = require("components.SmolButton")
local BasicButton = require("components.BasicButton")
local BigText = require("components.BigText")
local Modal = require("components.Modal")
local Logs = require("components.Logs")
local ConfigEditor = require("components.ConfigEditor")
local bigFont = require("fonts.bigfont")
local SmolText = require("components.SmolText")
local smolFont = require("fonts.smolfont")
local BasicText = require("components.BasicText")
local Rect = require("components.Rect")
local RenderCanvas = require("components.RenderCanvas")
local Core = require("core.ShopState")
local Pricing = require("core.Pricing")
local ShopRunner = require("core.ShopRunner")
local ConfigValidator = require("core.ConfigValidator")
local defaultLayout = require("DefaultLayout")
local loadRIF = require("modules.rif")
local configDefaults = require("configDefaults")
local config = misc.plainDeepCopy(require("config"))
local products = misc.plainDeepCopy(require("products"))
local eventHooks = {}
if fs.exists(fs.combine(fs.getDir(shell.getRunningProgram()), "eventHooks.lua")) then
eventHooks = require("eventHooks")
end
--- End Imports
configHelpers.loadDefaults(config, configDefaults)
-- these are for the GUI config editor, so that it doesnt save runtime stuff
local editConfig = misc.plainDeepCopy(config)
local editProducts = misc.plainDeepCopy(products)
local configErrors = ConfigValidator.validateConfig(config)
local productsErrors = ConfigValidator.validateProducts(products)
if (configErrors and #configErrors > 0) or (productsErrors and #productsErrors > 0) then
config.ready = false
else
config.ready = true
end
local peripherals = {}
configHelpers.getPeripherals(config, peripherals)
-- if config.shopSync and config.shopSync.enabled and not config.shopSync.force then
-- error("ShopSync is not yet finalized, please update Radon to use this feature, or set config.shopSync.force to true to use current ShopSync spec")
-- end
local display = Display.new({theme=config.theme, monitor=config.peripherals.monitor})
local terminal = Display.new({theme=config.terminalTheme, monitor=term})
local layout = nil
local layoutFile = nil
local layoutRenderer = nil
local configState = {
config = config,
products = products,
eventHooks = eventHooks,
editConfig = editConfig,
editProducts = editProducts,
}
local Main = Solyd.wrapComponent("Main", function(props)
local canvas = useCanvas(display)
local flatCanvas = {}
if props.configState.config.ready and props.shopState.kryptonReady then
local theme = props.configState.config.theme
if theme.formatting.layout ~= "custom" or not theme.formatting.layoutFile then
local addBg = false
if theme.formatting.layout ~= layout then
layout = theme.formatting.layout
if theme.palette then
require("util.setPalette")(display.mon, theme.palette)
end
if theme.colors and theme.colors.bgColor then
display.ccCanvas.clear = theme.colors.bgColor
addBg = true
end
display.ccCanvas:outputFlush(display.mon)
end
flatCanvas = defaultLayout(canvas, display, props, theme, version)
if addBg then
table.insert(flatCanvas, Rect {
display = display,
x = 1,
y = 1,
width = display.bgCanvas.width,
height = display.bgCanvas.height,
color = theme.colors.bgColor,
})
end
else
local addBg = false
if theme.formatting.layoutFile ~= layoutFile or theme.formatting.layout ~= layout then
layout = "custom"
layoutFile = theme.formatting.layoutFile
local f = fs.open(layoutFile, "r")
if not f then
error("Could not open layout file: " .. layoutFile)
end
layoutRendererString = f.readAll()
f.close()
local loadedString, err = load(layoutRendererString, layoutFile, "t", setmetatable({ require = require }, {__index = _ENV}))
if not loadedString then
error("Could not load layout file: " .. err)
end
layoutRenderer = loadedString()
if theme.layouts[layoutFile] and theme.layouts[layoutFile].palette then
require("util.setPalette")(display.mon, theme.layouts[layoutFile].palette)
end
if theme.layouts[layoutFile] and theme.layouts[layoutFile].colors and theme.layouts[layoutFile].colors.bgColor then
display.ccCanvas.clear = theme.layouts[layoutFile].colors.bgColor
addBg = true
end
display.ccCanvas:outputFlush(display.mon)
end
flatCanvas = layoutRenderer(canvas, display, props, theme, version)
if addBg then
table.insert(flatCanvas, 1, Rect {
display = display,
x = 1,
y = 1,
width = display.bgCanvas.width,
height = display.bgCanvas.height,
color = theme.layouts[layoutFile].colors.bgColor,
})
end
end
elseif not props.configState.config.ready then
flatCanvas = {
BasicText({
display = display,
text = "Waiting on config...",
x = 1,
y = 1,
color = colors.white,
bgColor = colors.black,
})
}
else
flatCanvas = {
BasicText({
display = display,
text = "Connecting...",
x = 1,
y = 1,
color = colors.white,
bgColor = colors.black,
})
}
end
return _.flat({ _.flat(flatCanvas) }), {
canvas = {canvas, 1, 1},
config = props.configState.config or {},
shopState = props.shopState or {},
products = props.shopState.products,
peripherals = props.peripherals or {},
}
end)
local terminalState = {
prevCatagory = "logs",
activeCatagory = "logs",
configErrors = configErrors,
productsErrors = productsErrors,
scroll = 0,
maxScroll = 0,
}
--local mbsMode = settings.get("mbs.shell.enabled")
local Terminal = Solyd.wrapComponent("Terminal", function(props)
local canvas = useCanvas(terminal)
local theme = props.configState.config.terminalTheme
local flatCanvas = {}
local versionString = version
local terminalCatagories = { "logs", "config", "products" }
local bodyHeight = math.floor(terminal.bgCanvas.height / 3) - 1
local bodyWidth = math.floor(terminal.bgCanvas.width / 2)
table.insert(flatCanvas, Rect {
key = "header",
display = terminal,
x = 1,
y = 1,
width = terminal.bgCanvas.width,
height = 3,
color = theme.colors.titleBgColor,
})
table.insert(flatCanvas, BasicText {
key = "title",
display = terminal,
align = "left",
text = versionString,
x = 1,
y = 1,
color = theme.colors.titleTextColor,
bg = theme.colors.titleBgColor,
})
local catagoriesX = 1 + #versionString
for i = 1, #terminalCatagories do
local bgColor = theme.colors.catagoryBgColor
if props.terminalState.activeCatagory == terminalCatagories[i] then
bgColor = theme.colors.activeCatagoryBgColor
end
table.insert(flatCanvas, BasicButton {
key = "catagory-" .. terminalCatagories[i],
display = terminal,
align = "center",
text = " " .. terminalCatagories[i] .. " ",
x = 2 + catagoriesX,
y = 1,
color = theme.colors.catagoryTextColor,
bg = bgColor,
onClick = function()
props.terminalState.activeCatagory = terminalCatagories[i]
end
})
catagoriesX = catagoriesX + 3 + #terminalCatagories[i]
end
if (props.terminalState.configErrors and #props.terminalState.configErrors > 0) or terminalState.activeCatagory == "config" then
if terminalState.prevCatagory ~= "config" then
terminalState.prevCatagory = "config"
terminalState.configPath = ""
terminalState.scroll = 0
end
table.insert(flatCanvas, ConfigEditor {
key = "config-editor",
display = terminal,
x = 1,
y = 2,
width = bodyWidth,
height = bodyHeight,
config = props.configState.config,
editConfig = props.configState.editConfig,
schema = schemas.configSchema,
errors = props.terminalState.configErrors,
errorPrefix = "config",
terminalState = props.terminalState,
theme = theme.colors.configEditor,
onSave = function(newConfig, newEditConfig)
props.shopState.oldConfig = props.configState.config
props.shopState.config = newConfig
props.configState.config = newConfig
props.configState.editConfig = newEditConfig
props.terminalState.configErrors = ConfigValidator.validateConfig(newConfig)
if (not props.terminalState.configErrors or #props.terminalState.configErrors == 0) and (not props.terminalState.productsErrors or #props.terminalState.productsErrors == 0) then
newConfig.ready = true
end
configHelpers.getPeripherals(newConfig, peripherals)
-- TODO: Detect if we actually need to update currencies
props.shopState.changedCurrencies = true
local serialized = textutils.serialize(newEditConfig)
local f = fs.open("config.lua", "w")
f.write("return " .. serialized)
f.close()
print("Configs updated!")
if props.configState.eventHooks and props.configState.eventHooks.configSaved then
props.configState.eventHooks.configSaved(newEditConfig)
end
end
})
elseif (props.terminalState.productsErrors and #props.terminalState.productsErrors > 0) or terminalState.activeCatagory == "products" then
if terminalState.prevCatagory ~= "products" then
terminalState.prevCatagory = "products"
terminalState.configPath = ""
terminalState.scroll = 0
end
table.insert(flatCanvas, ConfigEditor {
key = "products-editor",
display = terminal,
x = 1,
y = 2,
width = bodyWidth,
height = bodyHeight,
config = props.shopState.products,
editConfig = props.configState.editProducts,
schema = schemas.productsSchema,
errors = props.terminalState.productsErrors,
errorPrefix = "products",
terminalState = props.terminalState,
theme = theme.colors.productsEditor,
onSave = function(newConfig, newEditConfig)
props.shopState.products = newConfig
props.configState.products = newConfig
props.configState.editProducts = newEditConfig
props.terminalState.productsErrors = ConfigValidator.validateProducts(products)
if (not props.terminalState.configErrors or #props.terminalState.configErrors == 0) and (not props.terminalState.productsErrors or #props.terminalState.productsErrors == 0) then
props.configState.config.ready = true
end
ScanInventory.clearNbtCache()
local serialized = textutils.serialize(newEditConfig)
local f = fs.open("products.lua", "w")
f.write("return " .. serialized)
f.close()
print("Products updated!")
if props.configState.eventHooks and props.configState.eventHooks.productsSaved then
props.configState.eventHooks.productsSaved(newEditConfig)
end
end
})
elseif terminalState.activeCatagory == "logs" then
table.insert(flatCanvas, Logs {
key = "logs",
display = terminal,
x = 1,
y = 2,
width = bodyWidth,
height = bodyHeight,
logs = props.logs,
color = theme.colors.logTextColor,
bg = theme.colors.bgColor,
})
end
table.insert(flatCanvas, Modal { key="modal" })
return _.flat({ _.flat(flatCanvas) }), {
canvas = {canvas, 1, 1},
config = props.configState.config or {},
shopState = props.shopState or {},
products = props.shopState.products,
terminalState = props.terminalState,
peripherals = props.peripherals or {},
modal = props.modal
}
end)
local t = 0
local tree = nil
local terminalTree = nil
local lastClock = os.epoch("utc")
local lastCanvases = {
{ stack = {}, hash = {} },
{ stack = {}, hash = {} },
}
local function diffCanvasStack(diffDisplay, newStack, lastCanvas)
-- Find any canvases that were removed
local removed = {}
local kept, newCanvasHash = {}, {}
for i = 1, #lastCanvas.stack do
removed[lastCanvas.stack[i][1]] = lastCanvas.stack[i]
end
for i = 1, #newStack do
if removed[newStack[i][1]] then
kept[#kept+1] = newStack[i]
removed[newStack[i][1]] = nil
newStack[i][1].allDirty = false
else -- New
newStack[i][1].allDirty = true
end
newCanvasHash[newStack[i][1]] = newStack[i]
end
-- Mark rectangle of removed canvases on bgCanvas (TODO: using bgCanvas is a hack)
for _, canvas in pairs(removed) do
if canvas[1].brand == "TextCanvas" then
diffDisplay.bgCanvas:dirtyRect(canvas[2], canvas[3], canvas[1].width*2, canvas[1].height*3)
else
diffDisplay.bgCanvas:dirtyRect(canvas[2], canvas[3], canvas[1].width, canvas[1].height)
end
end
-- For each kept canvas, mark the bounds if the new bounds are different
for i = 1, #kept do
local newCanvas = kept[i]
local oldCanvas = lastCanvas.hash[newCanvas[1]]
if oldCanvas then
if oldCanvas[2] ~= newCanvas[2] or oldCanvas[3] ~= newCanvas[3] then
-- TODO: Optimize this?
if oldCanvas[1].brand == "TextCanvas" then
diffDisplay.bgCanvas:dirtyRect(oldCanvas[2], oldCanvas[3], oldCanvas[1].width*2, oldCanvas[1].height*3)
diffDisplay.bgCanvas:dirtyRect(newCanvas[2], newCanvas[3], newCanvas[1].width*2, newCanvas[1].height*3)
else
diffDisplay.bgCanvas:dirtyRect(oldCanvas[2], oldCanvas[3], oldCanvas[1].width, oldCanvas[1].height)
diffDisplay.bgCanvas:dirtyRect(newCanvas[2], newCanvas[3], newCanvas[1].width, newCanvas[1].height)
end
end
end
end
lastCanvas.stack = newStack
lastCanvas.hash = newCanvasHash
end
local shopState = Core.ShopState.new(config, products, peripherals, version, logs, eventHooks)
--local Profiler = require("profile")
local deltaTimer = os.startTimer(0)
local success, err = pcall(function() ShopRunner.launchShop(shopState, function()
--Profiler:activate()
print("Radon " .. version .. " started")
if eventHooks and eventHooks.start then
eventHook.execute(eventHooks.start, version, config, products, shopState)
end
while true do
-- add t = t if we need animations
tree = Solyd.render(tree, Main { configState = configState, shopState = shopState, peripherals = peripherals})
local context = Solyd.getTopologicalContext(tree, { "canvas", "aabb" })
diffCanvasStack(display, context.canvas, lastCanvases[1])
local t1 = os.epoch("utc")
local cstack = { {display.bgCanvas, 1, 1}, unpack(context.canvas) }
-- cstack[#cstack+1] = {display.textCanvas, 1, 1}
display.ccCanvas:composite(unpack(cstack))
display.ccCanvas:outputDirty(display.mon)
local t2 = os.epoch("utc")
--print("Render time: " .. (t2-t1) .. "ms")
terminalTree = Solyd.render(terminalTree, Terminal { configState = configState, products = products, shopState = shopState, peripherals = peripherals, logs = logs, terminalState = terminalState, modal = {}})
local terminalContext = Solyd.getTopologicalContext(terminalTree, { "canvas", "aabb", "input" })
diffCanvasStack(terminal, terminalContext.canvas, lastCanvases[2])
t1 = os.epoch("utc")
local cstackT = { {terminal.bgCanvas, 1, 1}, unpack(terminalContext.canvas) }
-- cstack[#cstack+1] = {display.textCanvas, 1, 1}
terminal.ccCanvas:composite(unpack(cstackT))
terminal.ccCanvas:outputDirty(terminal.mon)
t2 = os.epoch("utc")
-- print("Render time: " .. (t2-t1) .. "ms")
local activeNode = hooks.findActiveInput(terminalContext.input)
if activeNode then
if activeNode.inputState.cursorX and activeNode.inputState.cursorY then
terminal.mon.setCursorPos(activeNode.inputState.cursorX, activeNode.inputState.cursorY)
else
terminal.mon.setCursorPos(activeNode.x, activeNode.y)
end
terminal.mon.setTextColor(colors.black)
terminal.mon.setCursorBlink(true)
else
terminal.mon.setCursorBlink(false)
end
local receivedEvent = false
local terminate = false
while not receivedEvent do
local e = { os.pullEvent() }
receivedEvent = true
local name = e[1]
if name == "timer" and e[2] == deltaTimer then
-- local clock = os.epoch("utc")
-- local dt = (clock - lastClock)/1000
-- t = t + dt
-- lastClock = clock
-- deltaTimer = os.startTimer(0)
-- hooks.tickAnimations(dt)
elseif name == "timer" then
receivedEvent = false
elseif name == "monitor_touch" and e[2] == peripheral.getName(display.mon) then
local x, y = e[3], e[4]
local node = hooks.findNodeAt(context.aabb, x, y)
if node then
node.onClick()
end
elseif name == "term_resize" then
terminal.ccCanvas:outputFlush(terminal.mon)
elseif name == "mouse_click" then
local x, y = e[3], e[4]
local clearedInput = hooks.clearActiveInput(terminalContext.input, x, y)
if clearedInput and clearedInput.onBlur then
clearedInput.onBlur()
end
local node = hooks.findNodeAt(terminalContext.aabb, x, y)
if node then
node.onClick()
end
elseif name == "mouse_scroll" then
local dir = e[2]
local x, y = e[3], e[4]
local node = hooks.findNodeAt(terminalContext.aabb, x, y)
local cancelScroll = false
if node and node.onScroll then
if node.onScroll(dir) then
cancelScroll = true
end
end
if not cancelScroll then
if dir >= 1 and terminalState.scroll < terminalState.maxScroll then
terminalState.scroll = math.min(terminalState.scroll + dir, terminalState.maxScroll)
elseif dir <= -1 and terminalState.scroll > 0 then
terminalState.scroll = math.max(terminalState.scroll + dir, 0)
end
end
elseif name == "char" then
local char = e[2]
local node = hooks.findActiveInput(terminalContext.input)
if node then
node.onChar(char)
end
elseif name == "key" then
--if e[2] == keys.q then
-- break
--end
local key, held = e[2] or 0, e[3] or false
local node = hooks.findActiveInput(terminalContext.input)
if node then
node.onKey(key, held)
end
elseif name == "paste" then
local contents = e[2]
local node = hooks.findActiveInput(terminalContext.input)
if node and node.onPaste then
node.onPaste(contents)
end
elseif name == "terminate" then
terminate = true
break
end
end
if terminate then
break
end
end
---Profiler:deactivate()
end) end)
display.mon.clear()
terminal.mon.setBackgroundColor(colors.black)
terminal.mon.setTextColor(colors.white)
terminal.mon.clear()
terminal.mon.setCursorPos(1,1)
for i = 1, #shopState.config.currencies do
local currency = shopState.config.currencies[i]
if (currency.krypton and currency.krypton.ws) then
currency.krypton.ws:disconnect()
end
end
os.pullEvent = oldPullEvent
if not success then
if eventHooks and eventHooks.programError then
eventHook.execute(eventHooks.programError, err)
end
error(err)
end
print("Radon terminated, goodbye!")
--Profiler:write_results(nil, "profile.txt")
end
preload["profile"] = function(...)
--| Profiler module.
--b "Pedro Miller Rabinovitch" <
[email protected]>
--$Id: prof.lua,v 1.4 2003/10/17 00:17:21 miller Exp $
--TODO add function call profiling. Some of the skeleton is already in
--- place, but call profiling behaves different, so a couple of new
--- functionalities must be added.
--TODO add methods for proper result retrieval
--TODO use optional clock() function for millisecond precision, if it's
--- available
local E, I = {}, {}
--& Profiler module.
-- Profiler = E
--. Keeps track of the hit counts of each item
E.counts = {
line = {}
}
--. These should be inside the _line_ table above.
E.last_line = nil
E.last_time = os.epoch("utc")
E.started, E.ended = nil, nil
local dontdie = 0
--% Activates the profiling system.
--@ [kind] (string) Optional hook kind. For now, only 'line' works,
--- so just avoid it. >: )
function E:activate( kind )
kind = kind or 'line'
local function hook_counter( hk_name, param,... )
local t = self.counts[hk_name][param]
if t == nil then
t = { count=0, time = 0, name = debug.getinfo(2).short_src }
self.counts[hk_name][param] = t
end
self.counts[hk_name][param].count =
self.counts[hk_name][param].count + 1
dontdie = dontdie + 1
if dontdie > 1000000 then
dontdie = 0
print("yielding")
self.ended = os.epoch("utc")
E:write_results("line", "profile.txt")
coroutine.yield()
end
if self.last_line then
local delta = os.epoch("utc") - self.last_time
if delta > 0 then
self.counts[hk_name][self.last_line].time =
self.counts[hk_name][self.last_line].time + delta
self.last_time = os.epoch("utc")
end
end
self.last_line = param
end
self.started = os.epoch("utc")
debug.sethook( hook_counter, kind )
end
--% Deactivates the profiling system.
--@ [kind] (string) Optional hook kind. For now, only 'line' works,
--- so just avoid it.
function E:deactivate( kind )
kind = kind or 'line'
self.ended = os.epoch("utc")
debug.sethook( nil, kind )
end
--% Prints the results.
--@ [kind] (string) Optional hook... Aah, you got it by now.
--TODO add print output formatting and sorting
function E:print_results( kind )
kind = kind or 'line'
print( kind, 'count', 'approx. time (s)' )
print( '----', '-----', '----------------' )
for i,v in pairs( self.counts[kind] ) do
print( i, v.count, v.time )
end
print( self.ended - self.started,
' second(s) total (approximately).' )
end
function E:write_results( kind, filename )
kind = kind or 'line'
local f = io.open( filename, 'w' )
f:write( 'file', kind, ' count', ' approx. time (s)', '\n' )
f:write( '-------------', '----', ' -----', ' ----------------', '\n' )
for i,v in pairs( self.counts[kind] ) do
f:write( v.name, ' ', i, ' ', v.count, ' ', v.time, '\n' )
end
f:write( self.ended - self.started,
' second(s) total (approximately).', '\n' )
f:close()
end
return E
end
preload["modules.solyd"] = function(...)
--[[
MIT License
Copyright (c) 2022 emmachase
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 _ = require("util.score")
local Solyd = {} -- like lyqyd but not
local __hook
local function getKey()
local key = __hook.__volatile.key
if key == nil then
key = 1
__hook.__volatile.key = 2
else
__hook.__volatile.key = key + 1
end
return key
end
---Helper definition for the lazy variant of useState, the type system isn't stronk enough otherwise
---@alias UseStateFn<T> fun(initial: fun(): T): T, fun(newValue: T): T
---@alias UseState<T> fun(initial: T): T, fun(newValue: T): T
local setChange = false
---Use State as a hook, sets the initial value if it is not set.
---@generic T
---@param initial T
---@return T, fun(value: T): T
function Solyd.useState(initial)
local key = getKey()
local state = __hook[key]
if state == nil then
state = { value = type(initial) == "function" and initial() or initial }
__hook[key] = state
end
state.dirty = false
local function setState(newState)
state.value = newState
state.dirty = true
setChange = true
return newState
end
return state.value, setState
end
---Returns returns a mutable ref object whose .value property is initialized to the passed argument {initial}.
---The returned object will persist for the full lifetime of the component.
---@generic T
---@param initial fun(): T
---@return { value: T }
function Solyd.useRef(initial)
local key = getKey()
local ref = __hook[key]
if ref == nil then
ref = { value = initial() }
__hook[key] = ref
end
return ref
end
---Pass a “create” function and an array of dependencies. useMemo will only recompute the memoized
---value when one of the dependencies has changed. This optimization helps to avoid expensive
---calculations on every render.
---@generic T
---@param fun fun(): T
---@param deps any[]?
---@return T
function Solyd.useMemo(fun, deps)
local key = getKey()
local memo = __hook[key]
if memo == nil then
memo = { value = fun() }
__hook[key] = memo
end
if memo.deps == nil then
memo.deps = deps
else
for i, v in ipairs(deps) do
if v ~= memo.deps[i] then
memo.value = fun()
memo.deps = deps
break
end
end
end
return memo.value
end
function Solyd.useCallback(fun, deps)
return Solyd.useMemo(function() return fun end, deps)
end
---Accepts a function that contains imperative, possibly effectful code.
---The function passed to useEffect may return a clean-up function
---@param fun fun(): fun()?
---@param deps any[]?
function Solyd.useEffect(fun, deps)
local key = getKey()
local memo = __hook[key]
if memo == nil then
memo = { unmount = fun() }
__hook[key] = memo
end
if memo.deps == nil then
memo.deps = deps
else
for i, v in ipairs(deps) do
if v ~= memo.deps[i] then
memo.unmount()
memo.deps = deps
memo.unmount = fun()
break
end
end
end
end
---Get a value from the component context using the given key, returns nil if the key is not found.
---@generic T
---@param key any
---@return T?
function Solyd.useContext(key)
local parentHookCtx = __hook.__volatile.parentContext
while parentHookCtx do
if parentHookCtx.context and parentHookCtx.context[key] then
parentHookCtx.contextConsumers[key] = parentHookCtx.contextConsumers[key] or {}
for i = 1, #parentHookCtx.contextConsumers[key] do
if parentHookCtx.contextConsumers[key][i] == __hook then
return parentHookCtx.context[key]
end
end
table.insert(parentHookCtx.contextConsumers[key], __hook)
__hook.contextSubscriptions = __hook.contextSubscriptions or {}
__hook.contextSubscriptions[key] = parentHookCtx.contextConsumers
return parentHookCtx.context[key]
end
parentHookCtx = parentHookCtx.__volatile and parentHookCtx.__volatile.parentContext
end
return nil
end
---Gets the topologically ordered list of context values that were assigned with the given keys.
---@param tree table?
---@param keys string[]
---@return table
function Solyd.getTopologicalContext(tree, keys)
local values = {}
for i, key in ipairs(keys) do
values[key] = {}
end
if not tree then
return values
end
local queue = {tree}
while #queue > 0 do
local node = table.remove(queue, 1)
if node.context then
for i, key in ipairs(keys) do
if node.context[key] then
table.insert(values[key], node.context[key])
end
end
end
if node.dom then
if node.dom.src and node.dom.src.__tag == "element" then
table.insert(queue, 1, node.dom)
else
for i = #node.dom, 1, -1 do
if type(node.dom[i]) == "table" and node.dom[i].src then
assert(node.dom[i].src.__tag == "element", "Invalid tree")
table.insert(queue, 1, node.dom[i])
end
end
end
end
end
return values
end
---@alias ElementType "element"
---@alias SolydElement { __tag: ElementType, props: table, propsDiff: table, key: any?, component: fun(props: table): SolydElement | SolydElement[] }
---Create a Solyd element from a comonent and props.
---@generic P: table
---@param component fun(P): table The component function
---@param props P
---@param key any?
---@return SolydElement
function Solyd.createElement(name, component, props, key)
return { __tag="element", name = name, component = component, props = props, propsDiff = _.copyDeep(props), key = key }
end
local function propsChanged(oldProps, newProps)
if type(newProps) ~= "table" or newProps.__opaque ~= nil then
return oldProps ~= newProps
end
if type(oldProps) ~= type(newProps) then
return true
end
local keySet = {}
for k, v in pairs(newProps) do
keySet[k] = true
if type(v) == "table" and v.__opaque == nil then
if propsChanged(oldProps[k], v) then
return true
end
elseif oldProps[k] ~= v then
return true
end
end
for k, v in pairs(oldProps) do
if not keySet[k] then
return true
end
end
return false
end
-- local function tableSize(t)
-- if type(t) ~= "table" then
-- return nil
-- end
-- local count = 0
-- for k, v in pairs(t) do
-- count = count + 1
-- end
-- return count
-- end
---Call any unmount functions bottom-up to ensure everything is cleaned up.
local function _unmount(node)
if node.dom then
if node.dom.src and node.dom.src.__tag == "element" then
_unmount(node.dom)
else
for i = 1, #node.dom do
_unmount(node.dom[i])
end
end
end
if node.src and node.src.component then
for k, v in pairs(node.hook) do
if type(v) == "table" and v.unmount then
v.unmount()
end
end
if node.hook.contextSubscriptions then
for key, consumers in pairs(node.hook.contextSubscriptions) do
for i = 1, #consumers do
if consumers[i] == node.hook then
table.remove(consumers, i)
break
end
end
end
end
end
end
---Renders a Solyd element via tree expansion, pass the return value to the next invocation.
---@param previousTree table?
---@param rootComponent SolydElement?
---@return table?
local function _render(previousTree, rootComponent, parentContext, forceRender)
local nextTree = previousTree
if type(rootComponent) ~= "table" then
return { src = nil }
end
if forceRender
or not previousTree
or propsChanged(previousTree.src.propsDiff, rootComponent.propsDiff)
-- or propsChanged(previousTree.hook.__volatile.parentContext, parentContext)
then
-- upkeep
local hook = previousTree and previousTree.hook or { contextConsumers = {} }
hook.__volatile = {
__opaque = true,
previousTree = previousTree,
rootComponent = rootComponent,
parentContext = parentContext or {}
}
__hook = hook
-- update
local newTree, context = rootComponent.component(rootComponent.props)
if context and context.gameState then
-- print(hook.contextDiff, context.gameState, propsChanged(hook.contextDiff, context))
end
-- TODO: Optimize only call context consumers for the context that changed
if propsChanged(hook.contextDiff, context) then
for k, v in pairs(hook.contextConsumers or {}) do
for i = 1, #v do
v[i].contextDirty = { dirty = true }
end
end
hook.contextDiff = _.copyDeep(context)
end
hook.context = context
-- hook.contextConsumers = {}
if not newTree then
-- Check if we need to unmount children
if previousTree and previousTree.dom and previousTree.dom.src then
if previousTree.dom.src.__tag == "element" then
_unmount(previousTree.dom)
else
for i = 1, #previousTree.dom do
_unmount(previousTree.dom[i])
end
end
end
nextTree = { src = rootComponent, context = context, hook = hook }
else
-- TODO: verif ythat support swapping between singel and multiple children returned
if newTree.__tag == "element" and ((not previousTree) or previousTree.dom.src ~= nil) then
local oldChild = previousTree and previousTree.dom
if oldChild and oldChild.src.component == newTree.component then
nextTree = { src = rootComponent, hook = hook, context = context, dom = _render(oldChild, newTree, hook) }
else
if oldChild then
_unmount(previousTree) -- component changed, unmount old tree
end
nextTree = { src = rootComponent, hook = hook, context = context, dom = _render(nil, newTree, hook) }
end
else
local oldChildren = previousTree and previousTree.dom ---@type table?
-- this supports the swapping behavior
if oldChildren and oldChildren.src ~= nil then
oldChildren = { oldChildren }
end
if newTree.__tag == "element" then
newTree = { newTree }
end
local children = {}
local previousUniqueComponents = previousTree and previousTree.keyed or {}
local nextUniqueComponents, nIndex = {}, 0
for i, child in ipairs(newTree) do
if type(child) == "table" and child.key then
local prevMatch = previousUniqueComponents[child.key]
if prevMatch and prevMatch.src and prevMatch.src.component ~= child.component then
prevMatch = nil
end
local newChild = _render(prevMatch, child, hook)
nextUniqueComponents[child.key] = newChild
children[i] = newChild
elseif type(child) == "table" then
nIndex = nIndex + 1
local prevMatch = previousUniqueComponents[nIndex]
if prevMatch and prevMatch.src and prevMatch.src.component ~= child.component then
prevMatch = nil
end
local newChild = _render(prevMatch, child, hook)
nextUniqueComponents[nIndex] = newChild
children[i] = newChild
else
-- should not mount
nIndex = nIndex + 1
children[i] = { src = nil }
end
end
-- Find any children that no longer exist in the new tree
for key, child in pairs(previousUniqueComponents) do
if child and not nextUniqueComponents[key] or nextUniqueComponents[key].src.component ~= child.src.component then
_unmount(child)
end
end
nextTree = { src = rootComponent, hook = hook, context = context, dom = children, keyed = nextUniqueComponents }
end
end
hook.__volatile.nextTree = nextTree
end
return nextTree
end
---Traverses the tree to find dirty nodes and re-renders them, updating them in-place.
---@param tree table?
---@return table?
local function _cleanDirty(tree)
local queue = {{nil, tree, nil}}
local queuedMounts = {}
while #queue > 0 do
local didUpdate = false
local pair = table.remove(queue, 1)
local parent, node = pair[1], pair[2]
local originalNode = node
if node.hook then
local dirty = false
for k, v in pairs(node.hook) do
if type(v) == "table" and v.dirty then
dirty = true
v.dirty = false
end
end
if dirty then
local volatile = node.hook.__volatile
node = _render(volatile.nextTree, volatile.rootComponent, volatile.parentContext, true)
didUpdate = true
end
end
if didUpdate then
table.insert(queuedMounts, {parent, node, originalNode})
end
if node and node.dom then
if node.dom.src and node.dom.src.__tag == "element" then
table.insert(queue, {node, node.dom, parent})
else
for i = 1, #node.dom do
table.insert(queue, {node, node.dom[i], parent})
end
end
end
end
for i = 1, #queuedMounts do
local triple = queuedMounts[i]
local parent, node, originalNode = triple[1], triple[2], triple[3]
if parent and parent.dom then
if parent.dom and parent.dom.src and parent.dom.src.__tag == "element" then
assert(parent.dom == originalNode, "parent dom is not original node")
parent.dom = node
else
local found = false
for j = 1, #parent.dom do
if parent.dom[j] == originalNode then
parent.dom[j] = node
found = true
break
end
end
local found2 = false
for k, v in pairs(parent.keyed) do
if v == originalNode then
parent.keyed[k] = node
found2 = true
break
end
end
assert(found, "not found" .. parent.src.name)
assert(found2, "not found2" .. parent.src.name)
end
elseif not parent then
tree = node -- root
else
error("SHIT")
end
end
return tree
end
---Renders a Solyd element via tree expansion and sweeping, pass the return value to the next invocation.
---@param previousTree table?
---@param rootComponent SolydElement
---@return table?
function Solyd.render(previousTree, rootComponent)
local wasHook = __hook
local tree = _render(previousTree, rootComponent)
repeat
setChange = false
tree = _cleanDirty(tree)
until not setChange
__hook = wasHook -- Support nested render calls
return tree
end
---Wrap a component function to allow calling it directly without invoking createElement.
---@generic P: table
---@param component fun(props: P): table
---@return fun(props: P): table
function Solyd.wrapComponent(name, component)
return function(props)
return Solyd.createElement(name, component, props, props.key)
end
end
return Solyd
end
preload["modules.rif"] = function(...)
--[[
MIT License
Copyright (c) 2022 emmachase
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 canvases = require("modules.canvas")
local base64 = require("util.base64")
local PixelCanvas = canvases.PixelCanvas
-- local palMap = {
-- colors.black,
-- colors.blue,
-- colors.purple,
-- colors.green,
-- colors.brown,
-- colors.gray,
-- colors.lightGray,
-- colors.red,
-- colors.orange,
-- colors.yellow,
-- colors.lime,
-- colors.cyan,
-- colors.magenta,
-- colors.pink,
-- colors.darkGreen,
-- colors.white
-- }
local revPalMap = {
"black",
"blue",
"purple",
"green",
"brown",
"gray",
"lightGray",
"red",
"orange",
"yellow",
"lime",
"cyan",
"magenta",
"pink",
"darkGreen",
"white"
}
local palMap = {}
for i = 1, 16 do
palMap[i] = 2^(i - 1)
--colors[revPalMap[i]] = 2^(i - 1)
end
local fileCache = {}
return function(filename)
-- Riko 4 image format
local data = fileCache[filename]
if not data then
data = base64.decode(require(filename))
end
local width, height = data:byte(5) * 256 + data:byte(6), data:byte(7) * 256 + data:byte(8)
local canv = PixelCanvas(width, height)
local buffer = canv.canvas
for i = 1, math.ceil(width * height / 2) do
local byte = data:byte(19 + i)
local fp = bit.brshift(bit.band(byte, 240), 4)
local sp = bit.band(byte, 15)
-- 1, 3, 5
local pix = i * 2 - 1
local x = (pix - 1) % width + 1
local y = math.ceil(pix / width)
buffer[y][x] = palMap[fp + 1]
local x2 = pix % width + 1
local y2 = math.ceil((pix + 1) / width)
if buffer[y2] then
buffer[y2][x2] = palMap[sp + 1]
end
end
if data:byte(9) == 1 then
-- Transparency map
local max = 19 + math.ceil(width * height / 2)
for i = 1, math.ceil(width * height / 8) do
local byte = data:byte(max + i)
for j = 1, 8 do
local pix = (i - 1) * 8 + j
local x = (pix - 1) % width + 1
local y = math.ceil(pix / width)
if bit.band(byte, 2 ^ (j - 1)) ~= 0 then
if buffer[y] then
buffer[y][x] = nil
end
end
end
end
end
return canv
end
end
preload["modules.regex.util"] = function(...)
local util = {}
function util.deepClone(tab)
local nt = {}
for k, v in pairs(tab) do
if type(v) == "table" then
nt[k] = util.deepClone(v)
else
nt[k] = v
end
end
return nt
end
function util.nub(tab)
local entries = {}
local nt, i = {}, 1
for k, v in pairs(tab) do
if not entries[v] then
entries[v] = true
nt[i] = v
i = i + 1
end
end
return nt
end
return util
end
preload["modules.regex.reducer"] = function(...)
--[[
MIT License
Copyright (c) 2019 emmachase
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 dr = {}
local pprint = require("modules.regex.pprint")
local util = require("modules.regex.util")
local function isEpsilon(condition)
return type(condition) == "table" and condition.type == "epsilon"
end
local function traverseEpsilon(machine, state, seen)
seen = seen or {}
local func = {state}
local edges = machine.states[state].edges
for i = 1, #edges do
local edge = edges[i]
if isEpsilon(edge.condition) then
if not seen[edge.dest] then
seen[edge.dest] = true
-- Epsilon transition, add to E function
func[#func + 1] = edge.dest
-- Traverse through its destination and fully evaluate the epsilon path
local extraFn = traverseEpsilon(machine, edge.dest, seen)
-- Append those new states to this E function
local pos = #func
for j = 1, #extraFn do
func[pos + 1] = extraFn[j]
pos = pos + 1
end
end
end
end
return util.nub(func)
end
local function nameFromST(st)
table.sort(st)
return table.concat(st)
end
function dr.reduceNFA(nfa)
-- Construct E function
local eFunc = {}
for k in pairs(nfa.states) do
local eI = 1
eFunc[k] = traverseEpsilon(nfa, k)
end
local newMachine = {
states = {},
startState = nameFromST(eFunc[nfa.startState]),
acceptStates = {},
properties = nfa.properties
}
local todoStates = {eFunc[nfa.startState]}
local completeStates = {}
while todoStates[1] do -- while todoStates is not empty
local workingState = table.remove(todoStates, 1)
local newState = {
edges = {},
enter = {}
}
local lang = {}
local isAccepted = false
-- Get all possible inputs by traversing states
for i = 1, #workingState do
local stateName = workingState[i]
if nfa.acceptStates[stateName] then
isAccepted = true
end
local state = nfa.states[stateName]
for j = 1, #state.edges do
local cond = state.edges[j].condition
if type(cond) == "string" then
lang[cond] = lang[cond] or {}
lang[cond][#lang[cond] + 1] = state.edges[j].dest
end
end
end
-- For each possible input, compute the resultant state, and create an edge
for k, v in pairs(lang) do
local st, si = {}, 1
for i = 1, #v do
local throughput = eFunc[v[i]]
for j = 1, #throughput do
st[si] = throughput[j]
si = si + 1
end
end
st = util.nub(st)
table.sort(st)
local destState = nameFromST(st)
newState.edges[#newState.edges + 1] = {
condition = k,
dest = destState
}
if not completeStates[destState] then
todoStates[#todoStates + 1] = st
completeStates[destState] = true
end
end
-- Append each enter condition
local ei = 1
for i = 1, #workingState do
local stateName = workingState[i]
local state = nfa.states[stateName]
if state.enter then
for j = 1, #state.enter do
newState.enter[ei] = state.enter[j]
ei = ei + 1
end
end
end
local stateName = nameFromST(workingState)
newMachine.states[stateName] = newState
if isAccepted then
newMachine.acceptStates[stateName] = true
end
completeStates[stateName] = true
end
return newMachine
end
return dr
end
preload["modules.regex.pprint"] = function(...)
-- luacheck: ignore
local pprint = { VERSION = '0.1' }
local depth = 1
pprint.defaults = {
-- If set to number N, then limit table recursion to N deep.
depth_limit = false,
-- type display trigger, hide not useful datatypes by default
-- custom types are treated as table
show_nil = true,
show_boolean = true,
show_number = true,
show_string = true,
show_table = true,
show_function = false,
show_thread = false,
show_userdata = false,
-- additional display trigger
show_metatable = false, -- show metatable
show_all = false, -- override other show settings and show everything
use_tostring = false, -- use __tostring to print table if available
filter_function = nil, -- called like callback(value[,key, parent]), return truty value to hide
object_cache = 'local', -- cache blob and table to give it a id, 'local' cache per print, 'global' cache
-- per process, falsy value to disable (might cause infinite loop)
-- format settings
indent_size = 2, -- indent for each nested table level
level_width = 80, -- max width per indent level
wrap_string = true, -- wrap string when it's longer than level_width
wrap_array = false, -- wrap every array elements
sort_keys = true, -- sort table keys
}
local TYPES = {
['nil'] = 1, ['boolean'] = 2, ['number'] = 3, ['string'] = 4,
['table'] = 5, ['function'] = 6, ['thread'] = 7, ['userdata'] = 8
}
-- seems this is the only way to escape these, as lua don't know how to map char '\a' to 'a'
local ESCAPE_MAP = {
['\a'] = '\\a', ['\b'] = '\\b', ['\f'] = '\\f', ['\n'] = '\\n', ['\r'] = '\\r',
['\t'] = '\\t', ['\v'] = '\\v', ['\\'] = '\\\\',
}
-- generic utilities
local function escape(s)
s = s:gsub('([%c\\])', ESCAPE_MAP)
local dq = s:find('"')
local sq = s:find("'")
if dq and sq then
return s:gsub('"', '\\"'), '"'
elseif sq then
return s, '"'
else
return s, "'"
end
end
local function is_plain_key(key)
return type(key) == 'string' and key:match('^[%a_][%a%d_]*$')
end
local CACHE_TYPES = {
['table'] = true, ['function'] = true, ['thread'] = true, ['userdata'] = true
}
-- cache would be populated to be like:
-- {
-- function = { `fun1` = 1, _cnt = 1 }, -- object id
-- table = { `table1` = 1, `table2` = 2, _cnt = 2 },
-- visited_tables = { `table1` = 7, `table2` = 8 }, -- visit count
-- }
-- use weakrefs to avoid accidentall adding refcount
local function cache_apperance(obj, cache, option)
if not cache.visited_tables then
cache.visited_tables = setmetatable({}, {__mode = 'k'})
end
local t = type(obj)
-- TODO can't test filter_function here as we don't have the ix and key,
-- might cause different results?
-- respect show_xxx and filter_function to be consistent with print results
if (not TYPES[t] and not option.show_table)
or (TYPES[t] and not option['show_'..t]) then
return
end
if CACHE_TYPES[t] or TYPES[t] == nil then
if not cache[t] then
cache[t] = setmetatable({}, {__mode = 'k'})
cache[t]._cnt = 0
end
if not cache[t][obj] then
cache[t]._cnt = cache[t]._cnt + 1
cache[t][obj] = cache[t]._cnt
end
end
if t == 'table' or TYPES[t] == nil then
if cache.visited_tables[obj] == false then
-- already printed, no need to mark this and its children anymore
return
elseif cache.visited_tables[obj] == nil then
cache.visited_tables[obj] = 1
else
-- visited already, increment and continue
cache.visited_tables[obj] = cache.visited_tables[obj] + 1
return
end
for k, v in pairs(obj) do
cache_apperance(k, cache, option)
cache_apperance(v, cache, option)
end
local mt = getmetatable(obj)
if mt and option.show_metatable then
cache_apperance(mt, cache, option)
end
end
end
-- makes 'foo2' < 'foo100000'. string.sub makes substring anyway, no need to use index based method
local function str_natural_cmp(lhs, rhs)
while #lhs > 0 and #rhs > 0 do
local lmid, lend = lhs:find('%d+')
local rmid, rend = rhs:find('%d+')
if not (lmid and rmid) then return lhs < rhs end
local lsub = lhs:sub(1, lmid-1)
local rsub = rhs:sub(1, rmid-1)
if lsub ~= rsub then
return lsub < rsub
end
local lnum = tonumber(lhs:sub(lmid, lend))
local rnum = tonumber(rhs:sub(rmid, rend))
if lnum ~= rnum then
return lnum < rnum
end
lhs = lhs:sub(lend+1)
rhs = rhs:sub(rend+1)
end
return lhs < rhs
end
local function cmp(lhs, rhs)
local tleft = type(lhs)
local tright = type(rhs)
if tleft == 'number' and tright == 'number' then return lhs < rhs end
if tleft == 'string' and tright == 'string' then return str_natural_cmp(lhs, rhs) end
if tleft == tright then return str_natural_cmp(tostring(lhs), tostring(rhs)) end
-- allow custom types
local oleft = TYPES[tleft] or 9
local oright = TYPES[tright] or 9
return oleft < oright
end
-- setup option with default
local function make_option(option)
if option == nil then
option = {}
end
for k, v in pairs(pprint.defaults) do
if option[k] == nil then
option[k] = v
end
if option.show_all then
for t, _ in pairs(TYPES) do
option['show_'..t] = true
end
option.show_metatable = true
end
end
return option
end
-- override defaults and take effects for all following calls
function pprint.setup(option)
pprint.defaults = make_option(option)
end
-- format lua object into a string
function pprint.pformat(obj, option, printer)
option = make_option(option)
local buf = {}
local function default_printer(s)
table.insert(buf, s)
end
printer = printer or default_printer
local cache
if option.object_cache == 'global' then
-- steal the cache into a local var so it's not visible from _G or anywhere
-- still can't avoid user explicitly referentce pprint._cache but it shouldn't happen anyway
cache = pprint._cache or {}
pprint._cache = nil
elseif option.object_cache == 'local' then
cache = {}
end
local last = '' -- used for look back and remove trailing comma
local status = {
indent = '', -- current indent
len = 0, -- current line length
}
local wrapped_printer = function(s)
printer(last)
last = s
end
local function _indent(d)
status.indent = string.rep(' ', d + #(status.indent))
end
local function _n(d)
wrapped_printer('\n')
wrapped_printer(status.indent)
if d then
_indent(d)
end
status.len = 0
return true -- used to close bracket correctly
end
local function _p(s, nowrap)
status.len = status.len + #s
if not nowrap and status.len > option.level_width then
_n()
wrapped_printer(s)
status.len = #s
else
wrapped_printer(s)
end
end
local formatter = {}
local function format(v)
local f = formatter[type(v)]
f = f or formatter.table -- allow patched type()
if option.filter_function and option.filter_function(v, nil, nil) then
return ''
else
return f(v)
end
end
local function tostring_formatter(v)
return tostring(v)
end
local function number_formatter(n)
return n == math.huge and '[[math.huge]]' or tostring(n)
end
local function nop_formatter(v)
return ''
end
local function make_fixed_formatter(t, has_cache)
if has_cache then
return function (v)
return string.format('[[%s %d]]', t, cache[t][v])
end
else
return function (v)
return '[['..t..']]'
end
end
end
local function string_formatter(s, force_long_quote)
local s, quote = escape(s)
local quote_len = force_long_quote and 4 or 2
if quote_len + #s + status.len > option.level_width then
_n()
-- only wrap string when is longer than level_width
if option.wrap_string and #s + quote_len > option.level_width then
-- keep the quotes together
_p('[[')
while #s + status.len >= option.level_width do
local seg = option.level_width - status.len
_p(string.sub(s, 1, seg), true)
_n()
s = string.sub(s, seg+1)
end
_p(s) -- print the remaining parts
return ']]'
end
end
return force_long_quote and '[['..s..']]' or quote..s..quote
end
local function table_formatter(t)
if option.use_tostring then
local mt = getmetatable(t)
if mt and mt.__tostring then
return string_formatter(tostring(t), true)
end
end
local print_header_ix = nil
local ttype = type(t)
if option.object_cache then
local cache_state = cache.visited_tables[t]
local tix = cache[ttype][t]
-- FIXME should really handle `cache_state == nil`
-- as user might add things through filter_function
if cache_state == false then
-- already printed, just print the the number
return string_formatter(string.format('%s %d', ttype, tix), true)
elseif cache_state > 1 then
-- appeared more than once, print table header with number
print_header_ix = tix
cache.visited_tables[t] = false
else
-- appeared exactly once, print like a normal table
end
end
local limit = tonumber(option.depth_limit)
if limit and depth > limit then
if print_header_ix then
return string.format('[[%s %d]]...', ttype, print_header_ix)
end
return string_formatter(tostring(t), true)
end
local tlen = #t
local wrapped = false
_p('{')
_indent(option.indent_size)
_p(string.rep(' ', option.indent_size - 1))
if print_header_ix then
_p(string.format('--[[%s %d]] ', ttype, print_header_ix))
end
for ix = 1,tlen do
local v = t[ix]
if formatter[type(v)] == nop_formatter or
(option.filter_function and option.filter_function(v, ix, t)) then
-- pass
else
if option.wrap_array then
wrapped = _n()
end
depth = depth+1
_p(format(v)..', ')
depth = depth-1
end
end
-- hashmap part of the table, in contrast to array part
local function is_hash_key(k)
if type(k) ~= 'number' then
return true
end
local numkey = math.floor(tonumber(k))
if numkey ~= k or numkey > tlen or numkey <= 0 then
return true
end
end
local function print_kv(k, v, t)
-- can't use option.show_x as obj may contain custom type
if formatter[type(v)] == nop_formatter or
formatter[type(k)] == nop_formatter or
(option.filter_function and option.filter_function(v, k, t)) then
return
end
wrapped = _n()
if is_plain_key(k) then
_p(k, true)
else
_p('[')
-- [[]] type string in key is illegal, needs to add spaces inbetween
local k = format(k)
if string.match(k, '%[%[') then
_p(' '..k..' ', true)
else
_p(k, true)
end
_p(']')
end
_p(' = ', true)
depth = depth+1
_p(format(v), true)
depth = depth-1
_p(',', true)
end
if option.sort_keys then
local keys = {}
for k, _ in pairs(t) do
if is_hash_key(k) then
table.insert(keys, k)
end
end
table.sort(keys, cmp)
for _, k in ipairs(keys) do
print_kv(k, t[k], t)
end
else
for k, v in pairs(t) do
if is_hash_key(k) then
print_kv(k, v, t)
end
end
end
if option.show_metatable then
local mt = getmetatable(t)
if mt then
print_kv('__metatable', mt, t)
end
end
_indent(-option.indent_size)
-- make { } into {}
last = string.gsub(last, '^ +$', '')
-- peek last to remove trailing comma
last = string.gsub(last, ',%s*$', ' ')
if wrapped then
_n()
end
_p('}')
return ''
end
-- set formatters
formatter['nil'] = option.show_nil and tostring_formatter or nop_formatter
formatter['boolean'] = option.show_boolean and tostring_formatter or nop_formatter
formatter['number'] = option.show_number and number_formatter or nop_formatter -- need to handle math.huge
formatter['function'] = option.show_function and make_fixed_formatter('function', option.object_cache) or nop_formatter
formatter['thread'] = option.show_thread and make_fixed_formatter('thread', option.object_cache) or nop_formatter
formatter['userdata'] = option.show_userdata and make_fixed_formatter('userdata', option.object_cache) or nop_formatter
formatter['string'] = option.show_string and string_formatter or nop_formatter
formatter['table'] = option.show_table and table_formatter or nop_formatter
if option.object_cache then
-- needs to visit the table before start printing
cache_apperance(obj, cache, option)
end
_p(format(obj))
printer(last) -- close the buffered one
-- put cache back if global
if option.object_cache == 'global' then
pprint._cache = cache
end
return table.concat(buf)
end
-- pprint all the arguments
function pprint.pprint( ... )
local args = {...}
-- select will get an accurate count of array len, counting trailing nils
local len = select('#', ...)
for ix = 1,len do
pprint.pformat(args[ix], nil, io.write)
io.write('\n')
end
end
setmetatable(pprint, {
__call = function (_, ...)
pprint.pprint(...)
end
})
return pprint
end
preload["modules.regex.parser"] = function(...)
-- Regex Parser
-- Parses to an internal IL representation used for the construction of an NFA
--[[
MIT License
Copyright (c) 2019 emmachase
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 parser = {}
function parser.lexRegex(regexStr)
local termEaten
local function peek()
return regexStr:sub(1, 1)
end
local pos = 0
local function eatc()
local c = peek()
termEaten = termEaten .. c
regexStr = regexStr:sub(2)
pos = pos + 1
return c
end
local switchTable = {
["|"] = "union",
["*"] = function()
if peek() == "?" then
eatc()
return "ng-star"
end
return "star"
end,
["+"] = function()
if peek() == "?" then
eatc()
return "ng-plus"
end
return "plus"
end,
["?"] = "optional",
["("] = "l-paren",
[")"] = "r-paren",
["{"] = "l-bracket",
["}"] = "r-bracket",
["."] = "any",
["^"] = "start",
["$"] = "eos",
["\\"] = function()
local metas = {d = "[0-9]", w = "[a-zA-Z]", n = "\n"}
local c = eatc()
if metas[c] then
regexStr = metas[c] .. regexStr
pos = pos - #metas[c]
return false
end
termEaten = termEaten:sub(2)
return "char"
end,
["["] = function()
if peek() == "^" then
eatc()
return "open-negset"
end
return "open-set"
end,
["]"] = "close-set",
["-"] = "range"
}
local tokens = {}
while #regexStr > 0 do
termEaten = ""
local c = eatc()
local lexFn = switchTable[c]
local ret = "char"
if lexFn then
if type(lexFn) == "string" then
ret = lexFn
else
ret = lexFn()
end
end
if ret then
tokens[#tokens + 1] = {
type = ret,
source = termEaten,
position = pos
}
end
end
tokens[#tokens + 1] = {type = "eof", source = "", position = pos + 1}
return tokens
end
--[[
Grammar:
<RE> ::= <simple-RE> <union-list>
<union-list> ::= "|" <simple-RE> <union-list> | <lambda>
<simple-RE> ::= <basic-RE> <basic-RE-list>
<basic-RE-list> ::= <basic-RE> <basic-RE-list> | <lambda>
<basic-RE> ::= <star> | <plus> | <ng-star> | <ng-plus> | <quantifier> | <elementary-RE>
<star> ::= <elementary-RE> "*"
<plus> ::= <elementary-RE> "+"
<ng-star> ::= <elementary-RE> "*?"
<ng-plus> ::= <elementary-RE> "+?"
<quantifier> ::= <elementary-RE> "{" <quantity> "}"
<quantity> ::= <digit> "," <digit> | <digit> ","
<elementary-RE> ::= <group> | <any> | <eos> | <char> | <set>
<group> ::= "(" <RE> ")"
<any> ::= "."
<eos> ::= "$"
<char> ::= any non metacharacter | "\" metacharacter
<set> ::= <positive-set> | <negative-set>
<positive-set> ::= "[" <set-items> "]"
<negative-set> ::= "[^" <set-items> "]"
<set-items> ::= <set-item> | <set-item> <set-items>
<set-item> ::= <range> | <char>
<range> ::= <char> "-" <char>
Special Chars: | * + *? +? ( ) . $ \ [ [^ ] -
]]
function parser.parse(tokenList)
local RE, unionList, simpleRE, basicRE, basicREList, elementaryRE, quantifier, group, set, setItems, setItem
local parseTable = {
unionList = {["union"] = 1, default = 2},
basicREList = {["union"] = 2, ["r-paren"] = 2, ["eof"] = 2, default = 1},
elementaryRE = {["l-paren"] = 1, ["any"] = 2, ["char"] = 3, ["open-set"] = 4, ["open-negset"] = 4},
setItems = {["close-set"] = 1, default = 2}
}
local function eat()
return table.remove(tokenList, 1)
end
local function uneat(token)
table.insert(tokenList, 1, token)
end
local function expect(token, source)
local tok = eat()
if tok.type ~= token then
error("Unexpected token '" .. tok.type .. "' at position " .. tok.position, 0)
end
if source and not tok.source:match(source) then
error("Unexpected '" .. tok.source .. "' at position " .. tok.position, 0)
end
return tok
end
local function getMyType(name, index)
local parseFn = parseTable[name][tokenList[index or 1].type] or parseTable[name].default
if not parseFn then
error("Unexpected token '" .. tokenList[index or 1].type .. "' at position " .. tokenList[index or 1].position, 0)
end
return parseFn
end
local function unrollLoop(container)
local list, i = {}, 1
while container do
list[i], i = container[1], i + 1
container = container[2]
end
return unpack(list)
end
-- <RE> ::= <simple-RE> <union-list>
function RE()
return {type = "RE", simpleRE(), unrollLoop(unionList())}
end
-- <union-list> ::= "|" <simple-RE> <union-list> | <lambda>
function unionList()
local parseFn = getMyType("unionList")
if parseFn == 1 then
eat()
return {type = "unionList", simpleRE(), unionList()}
else
return
end
end
-- <simple-RE> ::= <basic-RE> <basic-RE-list>
function simpleRE()
return {type = "simpleRE", basicRE(), unrollLoop(basicREList())}
end
-- <basic-RE> ::= <star> | <plus> | <ng-star> | <ng-plus> | <quantifier> | <elementary-RE>
function basicRE()
local atom = elementaryRE()
local token = eat()
local type = token.type
if type == "star" then
return {type = "star", atom}
elseif type == "plus" then
return {type = "plus", atom}
elseif type == "ng-star" then
return {type = "ng-star", atom}
elseif type == "ng-plus" then
return {type = "ng-plus", atom}
elseif type == "optional" then
return {type = "optional", atom}
elseif type == "l-bracket" then
uneat(token)
return {type = "quantifier", atom, quantifier = quantifier()}
else
uneat(token)
return {type = "atom", atom}
end
end
-- <quantifier> ::= <elementary-RE> "{" <quantity> "}"
-- <quantity> ::= <digit> "," <digit> | <digit> ","
function quantifier()
expect("l-bracket")
local firstDigit = ""
do
local nextTok = expect("char", "%d")
local src = nextTok.source
repeat
firstDigit = firstDigit .. src
nextTok = eat()
src = nextTok.source
until src:match("%D")
uneat(nextTok)
end
if tokenList[1].type == "r-bracket" then
eat()
local count = tonumber(firstDigit)
return {type = "count", count = count}
end
expect("char", ",")
local secondDigit = ""
if tokenList[1].source:match("%d") then
local src, nextTok = ""
repeat
secondDigit = secondDigit .. src
nextTok = eat()
src = nextTok.source
until src:match("%D")
uneat(nextTok)
end
expect("r-bracket")
return {type = "range", min = tonumber(firstDigit), max = tonumber(secondDigit) or math.huge}
end
-- <basic-RE-list> ::= <basic-RE> <basic-RE-list> | <lambda>
function basicREList()
local parseFn = getMyType("basicREList")
if parseFn == 1 then
return {type = "basicREList", basicRE(), basicREList()}
else
return
end
end
-- <elementary-RE> ::= <group> | <any> | <char> | <set>
function elementaryRE()
local parseFn = getMyType("elementaryRE")
if parseFn == 1 then
return group()
elseif parseFn == 2 then
eat()
return {type = "any"}
elseif parseFn == 3 then
local token = eat()
return {type = "char", value = token.source}
elseif parseFn == 4 then
return set()
end
end
-- <group> ::= "(" <RE> ")"
function group()
eat()
local rexp = RE()
eat()
return {type = "group", rexp}
end
--<set> ::= <positive-set> | <negative-set>
function set()
local openToken = eat()
local ret
if openToken.type == "open-set" then
ret = {type = "set", unrollLoop(setItems())}
else -- open-negset
ret = {type = "negset", unrollLoop(setItems())}
end
eat()
return ret
end
-- <set-items> ::= <set-item> | <set-item> <set-items>
function setItems()
local firstItem = setItem()
local parseFn = getMyType("setItems")
if parseFn == 1 then
return {type = "setItems", firstItem}
else
return {type = "setItems", firstItem, setItems()}
end
end
-- <set-item> ::= <range> | <char>
function setItem()
if tokenList[2].type == "range" then
return {type = "range", start = eat().source, finish = (eat() and eat()).source}
else
return {type = "char", value = eat().source}
end
end
local props = {
clampStart = false,
clampEnd = false
}
if tokenList[1].type == "start" then
props.clampStart = true
table.remove(tokenList, 1)
end
if tokenList[#tokenList - 1].type == "eos" then
props.clampEnd = true
table.remove(tokenList, #tokenList - 1)
end
if #tokenList == 1 then
error("Empty regex", 0)
end
local ret = RE()
ret.properties = props
return ret
end
return parser
end
preload["modules.regex.nfactory"] = function(...)
--[[
MIT License
Copyright (c) 2019 emmachase
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 nf = {}
local util = require("modules.regex.util")
nf.epsilon = {type = "epsilon"} -- Special value for epsilon transition
local nameCounter = 0
local function genName()
nameCounter = nameCounter + 1
return "s" .. nameCounter
end
local function emptyMachine(noAccept)
local sName = genName()
local machine = {
states = {
[sName] = {edges = {}}
},
startState = sName,
acceptStates = {[sName] = true}
}
if noAccept then
machine.acceptStates = {}
end
return machine
end
local function addEnter(state, value)
state.enter = state.enter or {}
state.enter[#state.enter + 1] = value
end
function nf.semanticClone(machine)
local cmachine = util.deepClone(machine)
-- Rename all states so there are no collisions
local suffix = genName()
cmachine.startState = cmachine.startState .. suffix
local astates = {}
for k in pairs(cmachine.acceptStates) do
astates[#astates + 1] = k
end
for i = 1, #astates do
local k, v = astates[i], cmachine.acceptStates[astates[i]]
cmachine.acceptStates[k] = nil
cmachine.acceptStates[k .. suffix] = v
end
local states = {}
for k in pairs(cmachine.states) do
states[#states + 1] = k
end
for j = 1, #states do
local k, v = states[j], cmachine.states[states[j]]
for i = 1, #v.edges do
v.edges[i].dest = v.edges[i].dest .. suffix
end
cmachine.states[k] = nil
cmachine.states[k .. suffix] = v
end
return cmachine
end
function nf.concatMachines(first, second)
local newMachine = util.deepClone(first)
for k, v in pairs(second.states) do
newMachine.states[k] = v
end
for k in pairs(first.acceptStates) do
local xs = newMachine.states[k].edges
xs[#xs + 1] = {condition = nf.epsilon, dest = second.startState}
end
newMachine.acceptStates = {}
for k, v in pairs(second.acceptStates) do
newMachine.acceptStates[k] = v
end
return newMachine
end
function nf.unionMachines(first, second)
local newMachine = util.deepClone(first)
for k, v in pairs(second.states) do
newMachine.states[k] = v
end
for k, v in pairs(second.acceptStates) do
newMachine.acceptStates[k] = v
end
-- Link start state
local xs = newMachine.states[newMachine.startState].edges
xs[#xs + 1] = {condition = nf.epsilon, dest = second.startState}
return newMachine
end
function nf.generateFromCapture(atom)
local capture = atom[1]
local machine
if capture.type == "char" then
local sName, cName = genName(), genName()
machine = {
states = {
[sName] = {edges = {{condition = capture.value, dest = cName}}},
[cName] = {edges = {}}
},
startState = sName,
acceptStates = {[cName] = true}
}
elseif capture.type == "any" then
local sName, cName = genName(), genName()
machine = {
states = {
[sName] = {edges = {}},
[cName] = {edges = {}}
},
startState = sName,
acceptStates = {[cName] = true}
}
local sEdges = machine.states[sName].edges
for i = 1, 255 do
sEdges[#sEdges + 1] = {condition = string.char(i), dest = cName}
end
elseif capture.type == "set" or capture.type == "negset" then
local sName, cName = genName(), genName()
machine = {
states = {
[sName] = {edges = {}},
[cName] = {edges = {}}
},
startState = sName,
acceptStates = {[cName] = true}
}
local tState = {}
for i = 1, #capture do
local match = capture[i]
if match.type == "char" then
tState[match.value] = true
elseif match.type == "range" then
local dir = match.finish:byte() - match.start:byte()
dir = dir / math.abs(dir)
for j = match.start:byte(), match.finish:byte(), dir do
tState[string.char(j)] = true
end
end
end
local sEdges = machine.states[sName].edges
if capture.type == "set" then
for k in pairs(tState) do
sEdges[#sEdges + 1] = {condition = k, dest = cName}
end
else
for i = 1, 255 do
if not tState[string.char(i)] then
sEdges[#sEdges + 1] = {condition = string.char(i), dest = cName}
end
end
end
elseif capture.type == "group" then
machine = nf.generateNFA(capture[1])
local instance = genName()
addEnter(machine.states[machine.startState], "begin-group-" .. instance)
for k in pairs(machine.acceptStates) do
addEnter(machine.states[k], "end-group-" .. instance)
end
else
error("Unimplemented capture: '" .. capture.type .. "'")
end
if atom.type == "atom" then
return machine
elseif atom.type == "plus" then
local instance = genName()
addEnter(machine.states[machine.startState], "begin-sort-" .. instance)
for k in pairs(machine.acceptStates) do
local es = machine.states[k].edges
es[#es + 1] = {condition = nf.epsilon, dest = machine.startState}
-- Mark the state for recording, used for path reduction later
addEnter(machine.states[k], "maximize-" .. instance)
end
return machine
elseif atom.type == "ng-plus" then
local instance = genName()
addEnter(machine.states[machine.startState], "begin-sort-" .. instance)
for k in pairs(machine.acceptStates) do
local es = machine.states[k].edges
es[#es + 1] = {condition = nf.epsilon, priority = "low", dest = machine.startState}
-- Mark the state for recording
addEnter(machine.states[k], "minimize-" .. instance)
end
return machine
elseif atom.type == "star" then
local instance = genName()
addEnter(machine.states[machine.startState], "begin-sort-" .. instance)
local needStart = true
for k in pairs(machine.acceptStates) do
local es = machine.states[k].edges
es[#es + 1] = {condition = nf.epsilon, dest = machine.startState}
if k == machine.startState then
needStart = false
end
-- Mark the state for recording
addEnter(machine.states[k], "maximize-" .. instance)
end
if needStart then
machine.acceptStates[machine.startState] = true
end
return machine
elseif atom.type == "ng-star" then
local instance = genName()
addEnter(machine.states[machine.startState], "begin-sort-" .. instance)
local needStart = true
for k in pairs(machine.acceptStates) do
local es = machine.states[k].edges
es[#es + 1] = {condition = nf.epsilon, priority = "low", dest = machine.startState}
if k == machine.startState then
needStart = false
end
-- Mark the state for recording
addEnter(machine.states[k], "minimize-" .. instance)
end
if needStart then
machine.acceptStates[machine.startState] = true
end
return machine
elseif atom.type == "optional" then
machine.acceptStates[machine.startState] = true
return machine
elseif atom.type == "quantifier" then
local quantifier = atom.quantifier
if quantifier.type == "count" then
local single = machine
for _ = 2, quantifier.count do
machine = nf.concatMachines(single, nf.semanticClone(machine))
end
return machine
else -- range
local single = machine
for _ = 2, quantifier.min do
machine = nf.concatMachines(single, nf.semanticClone(machine))
end
if quantifier.max == math.huge then
local prevMachine = nf.semanticClone(machine)
machine = nf.concatMachines(prevMachine, util.deepClone(single))
for k, v in pairs(prevMachine.acceptStates) do
machine.acceptStates[k] = v
end
for k in pairs(machine.acceptStates) do
local es = machine.states[k].edges
es[#es + 1] = {condition = nf.epsilon, dest = single.startState}
end
else
-- All in this range are valid, so setup those links
for _ = quantifier.min + 1, quantifier.max do
local prevMachine = nf.semanticClone(machine)
machine = nf.concatMachines(prevMachine, util.deepClone(single))
for k, v in pairs(prevMachine.acceptStates) do
machine.acceptStates[k] = v
end
end
end
return machine
end
else
error("Unimplemented atom type: '" .. atom.type .. "'")
end
end
function nf.generateNFA(parsedRegex)
local machine = emptyMachine(true)
machine.properties = parsedRegex.properties
for i = 1, #parsedRegex do
-- Different branches
local branch = parsedRegex[i]
local tempMachine = emptyMachine()
for j = 1, #branch do
local capture = branch[j]
tempMachine = nf.concatMachines(tempMachine, nf.generateFromCapture(capture))
end
machine = nf.unionMachines(machine, tempMachine)
end
return machine
end
return nf
end
preload["modules.regex"] = function(...)
-- Main utilty file, exposes entire library
--[[
MIT License
Copyright (c) 2019 emmachase
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 r2l = {}
r2l.parser = require("modules.regex.parser")
r2l.nfactory = require("modules.regex.nfactory")
r2l.reducer = require("modules.regex.reducer")
r2l.emitter = require("modules.regex.emitter")
r2l.new = function(regex)
local tokens = r2l.parser.lexRegex(regex)
local parseSuccess, parsedRegex = pcall(r2l.parser.parse, tokens)
if not parseSuccess then
error("Failed to parse regex: " .. parsedRegex)
end
local origNFA = r2l.nfactory.generateNFA(parsedRegex)
local origDFA = r2l.reducer.reduceNFA(origNFA)
return loadstring(r2l.emitter.generateLua(origDFA))()
end
return r2l
end
preload["modules.regex.emitter"] = function(...)
--[[
MIT License
Copyright (c) 2019 emmachase
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 util = require("modules.regex.util")
local emitter = {}
-- local counter = 0
-- local function makeName()
-- counter = counter + 1
-- return "n" .. counter
-- end
--[[
local states = {
s1 = function(nextChar) ... end,
...
}
local acceptStates = {s1 = true, ...}
return function match(str)
local strlen = #str
local state = s1
local allMatches, ai = {}, 1
for startChar = 1, strlen do -- Conditional upon properties.clampStart
local ci = 0
while state and ci <= strlen do
if acceptStates[state] then
if ci == strlen then -- Conditional upon properties.clampEnd
allMatches[ai] = {str:sub(1, ci), startChar, ci + startChar - 1}
end
end
state = states[state](str:sub(ci + 1, ci + 1))
ci = ci + 1
end
end
return unpack(allMatches)
end
]]
local function generateFunction(state)
if #state.edges == 0 then
return "function() end"
end
local output = "function(char)"
local dests = {}
for i = 1, #state.edges do
local edge = state.edges[i]
local dest = edge.dest
dests[dest] = dests[dest] or {}
dests[dest][#dests[dest] + 1] = edge.condition
end
local prefix = "if"
for dest, conds in pairs(dests) do
output = output .. "\n " .. prefix
table.sort(conds)
local ranges = {}
local singles = {}
while #conds > 0 do
if #conds == 1 then
singles[#singles + 1] = conds[1]
break
elseif #conds == 2 then
singles[#singles + 1] = conds[1]
singles[#singles + 1] = conds[2]
break
end
local val, index = conds[1]:byte(), 2
while conds[index]:byte() - index + 1 == val do
index = index + 1
if index > #conds then
break
end
end
index = index - 1
if index == 1 then
singles[#singles + 1] = table.remove(conds, 1)
elseif index == 2 then
singles[#singles + 1] = table.remove(conds, 1)
singles[#singles + 1] = table.remove(conds, 1)
else
ranges[#ranges + 1] = {string.char(val), string.char(val + index - 1)}
for i = 1, index do
table.remove(conds, 1)
end
end
end
local first = true
for i = 1, #ranges do
local range = ranges[i]
if first then
first = false
else
output = output .. " or"
end
output = output .. " (char >= " .. range[1]:byte() .. " and char <= " .. range[2]:byte() .. ")"
-- if range[1] == "]" then
-- output = output .. "%]"
-- elseif range[1]:match("[a-zA-Z]") then
-- output = output .. range[1]
-- else
-- output = output .. "\\" .. range[1]:byte()
-- end
-- output = output .. "-"
-- if range[2] == "]" then
-- output = output .. "%]"
-- elseif range[2]:match("[a-zA-Z]") then
-- output = output .. range[2]
-- else
-- output = output .. "\\" .. range[2]:byte()
-- end
end
for i = 1, #singles do
if first then
first = false
else
output = output .. " or"
end
output = output .. " char == " .. singles[i]:byte()
-- if singles[i]:match("[a-zA-Z]") then
-- output = output .. singles[i]
-- else
-- output = output .. "%\\" .. singles[i]:byte()
-- end
end
output = output .. " then return " .. dest
prefix = "elseif"
end
output = output .. " end\n end"
return output
end
--[[
machine = {
states = {
[sName] = {edges = {}}
},
startState = sName,
acceptStates = {[sName] = true}
}
]]
local function numericize(dfa)
local newMachine = util.deepClone(dfa)
local oldNames = {}
local newNames = {}
local counter = 0
for k, v in pairs(dfa.states) do
counter = counter + 1
newMachine.states[k] = nil
newMachine.states[counter] = v
newNames[k] = counter
oldNames[counter] = k
end
for i = 1, counter do
local oldEdges = dfa.states[oldNames[i]].edges
local newEdges = newMachine.states[i].edges
local n = #oldEdges
for j = 1, n do
newEdges[j].dest = newNames[oldEdges[j].dest]
end
end
newMachine.startState = newNames[dfa.startState]
for k, v in pairs(dfa.acceptStates) do
newMachine.acceptStates[k] = nil
newMachine.acceptStates[newNames[k]] = v
end
return newMachine
end
function emitter.generateLua(dfa)
dfa = numericize(dfa)
local output = [[
local unpack = unpack or table.unpack
local states = {
]]
for i = 1, #dfa.states do
local state = dfa.states[i]
output = output .. " " .. generateFunction(state) .. ",\n"
end
output = output .. [[}
local stateEntries = {
]]
for i = 1, #dfa.states do
output = output .. " {"
local state = dfa.states[i]
local entries = util.nub(state.enter)
for j = 1, #entries do
output = output .. "'" .. entries[j] .. "',"
end
output = output .. "},\n"
end
output = output .. [[}
local acceptStates = {]]
for state in pairs(dfa.acceptStates) do
output = output .. "[" .. state .."] = true,"
end
output = output .. [[}
return function(str)
local strlen = #str
local allMatches, ai = {}, 1
]]
if dfa.properties.clampStart then
output = output .. "local startChar = 1 do\n"
else
output = output .. "for startChar = 1, strlen do\n"
end
output = output .. [[
local state = ]]
output = output .. dfa.startState
output = output .. [[
local ci = startChar - 1
while state and ci <= strlen do
if acceptStates[state] then
]]
if dfa.properties.clampEnd then
output = output .. [[
if ci == strlen then
]]
else
output = output .. [[
do
]]
end
output = output .. [[allMatches[ai] = {str:sub(startChar, ci), startChar, ci}
ai = ai + 1
end
end
local char = str:sub(ci + 1, ci + 1):byte()
if char then
state = states[state](char)
end
ci = ci + 1
end
end
local result
for i = 1, #allMatches do
if (not result) or #allMatches[i][1] > #result[1] then
result = allMatches[i]
end
end
if result then
return unpack(result)
end
end
]]
return output
end
return emitter
end
preload["modules.hooks.textCanvas"] = function(...)
--[[
MIT License
Copyright (c) 2022 emmachase
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 Solyd = require("modules.solyd")
local canvases = require("modules.canvas")
---@return TextCanvas
local function useTextCanvas(display, w, h)
local c = Solyd.useMemo(function()
return canvases.TextCanvas(w, h)
end, {display, w, h})
return c
-- local c = Solyd.useMemo(function()
-- return display.textCanvas
-- end, {display})
-- return c
end
return useTextCanvas
end
preload["modules.hooks.modals"] = function(...)
end
preload["modules.hooks.input"] = function(...)
--[[
MIT License
Copyright (c) 2022 emmachase
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 Solyd = require("modules.solyd")
local function useInput(x, y, width, height, inputState, onChar, onKey, onBlur, onPaste)
local input = Solyd.useRef(function()
return { x = x, y = y, width = width, height = height, inputState = inputState, onChar = onChar, onKey = onKey, onBlur = onBlur, onPaste = onPaste}
end).value
input.x = x
input.y = y
input.width = width
input.height = height
input.inputState = inputState
input.onChar = onChar
input.onKey = onKey
input.onBlur = onBlur
input.onPaste = onPaste
return input
end
local function findActiveInput(inputs)
-- x, y = x*2, y*3
for i = #inputs, 1, -1 do
local input = inputs[i]
if input.__type == "list" then
local node = findActiveInput(input)
if node then
return node
end
else
if input.inputState.active then
return input
end
end
end
end
local function clearActiveInput(inputs, x, y)
for i = #inputs, 1, -1 do
local input = inputs[i]
if input.__type == "list" then
clearActiveInput(input)
else
if input.inputState.active and (x*2 < input.x or x*2-1 >= input.x + input.width
or y*3 < input.y or y*3-2 >= input.y + input.height) then
input.inputState.active = false
return input
end
end
end
end
return {
useInput = useInput,
findActiveInput = findActiveInput,
clearActiveInput = clearActiveInput,
}
end
preload["modules.hooks"] = function(...)
--[[
MIT License
Copyright (c) 2022 emmachase
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.
]]
return {
useCanvas = require("modules.hooks.canvas"),
useTextCanvas = require("modules.hooks.textCanvas"),
useBoundingBox = require("modules.hooks.aabb").useBoundingBox,
findNodeAt = require("modules.hooks.aabb").findNodeAt,
useInput = require("modules.hooks.input").useInput,
findActiveInput = require("modules.hooks.input").findActiveInput,
clearActiveInput = require("modules.hooks.input").clearActiveInput,
useAnimation = require("modules.hooks.animation").useAnimation,
tickAnimations = require("modules.hooks.animation").tickAnimations,
}
end
preload["modules.hooks.canvas"] = function(...)
--[[
MIT License
Copyright (c) 2022 emmachase
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 Solyd = require("modules.solyd")
local canvases = require("modules.canvas")
---@return PixelCanvas
local function useCanvas(display, w,h)
local c = Solyd.useMemo(function()
if w then
return canvases.PixelCanvas(w, h)
else
return display.ccCanvas.pixelCanvas:newFromSize()
end
end, {display, w, h})
return c
end
return useCanvas
end
preload["modules.hooks.animation"] = function(...)
--[[
MIT License
Copyright (c) 2022 emmachase
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 Solyd = require("modules.solyd")
local animationRequests = {}
local animationFinished = {}
---@return number?
local function useAnimation(playing)
-- local anim = Solyd.useRef(function()
-- return { playing = playing, frame = 0, time = 0 }
-- end).value
local t, setT = Solyd.useState(0)
if playing then
-- Request animation frame
animationRequests[#animationRequests + 1] = {t, setT}
return t
elseif t ~= 0 then
print("reset")
setT(0)
return
else
-- print("ff", t)
end
end
local function tickAnimations(dt)
-- Clone the queue to avoid mutating it while iterating
local animationQueue = {unpack(animationRequests)}
animationRequests = {}
for _, v in ipairs(animationQueue) do
local aT, setT = v[1], v[2]
setT(aT + dt)
end
end
return {
useAnimation = useAnimation,
tickAnimations = tickAnimations,
animationFinished = animationFinished,
}
end
preload["modules.hooks.aabb"] = function(...)
--[[
MIT License
Copyright (c) 2022 emmachase
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 Solyd = require("modules.solyd")
local function useBoundingBox(x, y, w, h, onClick, onScroll)
local box = Solyd.useRef(function()
return { x = x, y = y, w = w, h = h, onClick = onClick, onScroll = onScroll }
end).value
box.x = x
box.y = y
box.w = w
box.h = h
box.onClick = onClick
box.onScroll = onScroll
return box
end
local function findNodeAt(boxes, x, y)
-- x, y = x*2, y*3
for i = #boxes, 1, -1 do
local box = boxes[i]
if box.__type == "list" then
local node = findNodeAt(box, x, y)
if node then
return node
end
else
if x*2 >= box.x and x*2-1 < box.x + box.w
and y*3 >= box.y and y*3-2 < box.y + box.h then
return box
end
end
end
end
return {
useBoundingBox = useBoundingBox,
findNodeAt = findNodeAt,
}
end
preload["modules.font"] = function(...)
--[[
MIT License
Copyright (c) 2022 emmachase
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 canvases = require("modules.canvas")
local PixelCanvas = canvases.PixelCanvas
---@class Font
---@field private chars { [string]: FontChar }
---@field height number
---@field kerning number
local Font = {}
local Font_mt = { __index = Font }
---@alias FontChar PixelCanvas
---@param char PixelCanvas
local function fixChar(char)
for y = 1, char.height do
for x = 1, char.width do
if char.canvas[y][x] == colors.black then
char.canvas[y][x] = nil
end
end
end
return char
end
---@param canvas PixelCanvas
---@param text string
---@param x integer
---@param y integer
---@param c integer
function Font:write(canvas, text, x, y, c)
local cx = x
for i = 1, #text do
local char = self.chars[text:sub(i, i)]
if char then
canvas:drawTint(char, cx, y, c)
cx = cx + char.width + self.kerning
end
end
end
---@param text string
function Font:getWidth(text)
local width = 0
for i = 1, #text do
local char = self.chars[text:sub(i, i)]
if char then
width = width + char.width + 1
end
end
if width == 0 then
return 0
end
return width - 1
end
---Write right aligned
---@param canvas PixelCanvas
---@param text string
---@param x integer
---@param y integer
---@param c integer
function Font:writeRight(canvas, text, x, y, c)
local width = self:getWidth(text)
self:write(canvas, text, x - width + 1, y, c)
end
---@param fontSheet PixelCanvas
---@param mapping string
---@return Font
return function(fontSheet, mapping)
local self = setmetatable({}, Font_mt)
self.height = fontSheet.height-1
self.chars = {}
self.kerning = 1
local charStartX = 1
local x = 1
for i = 1, #mapping do
local name = mapping:sub(i, i)
repeat
x = x + 1
local nc = fontSheet.canvas[self.height+1][x]
until x > fontSheet.width or (nc and nc ~= colors.black)
local char = PixelCanvas(x - charStartX, self.height)
char:drawCanvasClip(fontSheet, 1, 1, charStartX, 1, x - 1, self.height)
self.chars[name] = fixChar(char)
charStartX = x + 1
end
return self
end
end
preload["modules.display"] = function(...)
--[[
MIT License
Copyright (c) 2022 emmachase
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 Display = {}
local Display_mt = { __index = Display }
function Display.new(props)
local self = setmetatable({}, Display_mt)
local canvases = require("modules.canvas")
local PixelCanvas = canvases.PixelCanvas
local TeletextCanvas = canvases.TeletextCanvas
local TextCanvas = canvases.TextCanvas
self.mon = props.monitor
if self.mon and self.mon ~= term then
self.mon = peripheral.wrap(props.monitor)
end
if not self.mon and self.mon ~= term then
self.mon = peripheral.find("monitor")
end
if not self.mon then
self.mon = term
elseif self.mon ~= term then
self.mon.setTextScale(0.5)
end
-- Set Riko Palette
if props.theme and props.theme.palette then
require("util.setPalette")(self.mon, props.theme.palette)
end
local bgColor = colors.black
if props.theme and props.theme.colors and props.theme.colors.bgColor then
bgColor = props.theme.colors.bgColor
end
self.ccCanvas = TeletextCanvas(bgColor, self.mon.getSize())
self.ccCanvas:outputFlush(self.mon)
self.textCanvas = TextCanvas(bgColor, self.mon.getSize())
self.bgCanvas = self.ccCanvas.pixelCanvas:newFromSize()
-- for y = 1, self.bgCanvas.height do
-- for x = 1, self.bgCanvas.width do
-- -- T-Piece
-- self.bgCanvas:setPixel(x, y, props.theme.colors.headerBgColor)
-- end
-- end
return self
end
return Display
end
preload["modules.canvas"] = function(...)
--[[
MIT License
Copyright (c) 2022 emmachase
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 floor, ceil, min, max, abs, concat = math.floor, math.ceil, math.min, math.max, math.abs, table.concat
local function round(x) return floor(x + 0.5) end
local _ = require("util.score")
---@alias Color
---| 1
---| 2
---| 4
---| 8
---| 16
---| 32
---| 64
---| 128
---| 256
---| 512
---| 1024
---| 2048
---| 4096
---| 8192
---| 16384
---| 32768
local _ttxChars = setmetatable({}, {__index = error})
for i = 0, 31 do
_ttxChars[i+1] = string.char(128 + i)
end
local _hex = setmetatable({}, {__index = error})
for i = 0, 15 do
_hex[2^i] = string.format("%x", i)
end
---@alias Terminal table A computercraft terminal object
---@class PixelCanvas
---@field width integer
---@field height integer
---@field canvas { [integer]: { [integer]: integer } }
---@field dirty { [integer]: { [integer]: boolean } }
---@field allDirty boolean?
---@operator call:PixelCanvas
local PixelCanvas = {}
local PixelCanvas_mt = { __index = PixelCanvas }
setmetatable(PixelCanvas, { __call = function(_, ...) return PixelCanvas.new(...) end })
function PixelCanvas.new(width, height)
local self = setmetatable({__opaque = true}, PixelCanvas_mt)
self.width = width
self.height = height
self.canvas = {}
for y = 1, height do
self.canvas[y] = {}
for x = 1, width do
self.canvas[y][x] = nil
end
end
self.dirty = {} -- { [y] = { [x] = true } }
return self
end
---Set the pixel at {x}, {y} to {c}.
---@param x integer
---@param y integer
---@param c Color
function PixelCanvas:setPixel(x, y, c)
x, y = floor(x), floor(y)
-- TODO: remove bounds checking?
if x < 1 or x > self.width or y < 1 or y > self.height then
return
end
if self.canvas[y][x] ~= c then
self.canvas[y][x] = c
self.dirty[y] = self.dirty[y] or {}
self.dirty[y][x] = true
end
end
function PixelCanvas:clone()
local clone = PixelCanvas.new(self.width, self.height)
for y = 1, self.height do
clone.dirty[y] = {}
for x = 1, self.width do
clone.canvas[y][x] = self.canvas[y][x]
clone.dirty[y][x] = true
end
end
return clone
end
function PixelCanvas:mapColors(map)
for y = 1, self.height do
for x = 1, self.width do
self.canvas[y][x] = map[self.canvas[y][x]] or self.canvas[y][x]
end
end
return self
end
---Copies the contents of {canvas} into this canvas at {x, y}.
---@param canvas PixelCanvas
---@param x integer
---@param y integer
function PixelCanvas:drawCanvas(canvas, x, y, remapFrom, remapTo)
for cy = 1, canvas.height do
for cx = 1, canvas.width do
local c = canvas.canvas[cy][cx]
if c == remapFrom then
if type(remapTo) == "table" then error() end
c = remapTo
end
if c then -- TODO: document this
self:setPixel(x + cx - 1, y + cy - 1, c)
end
end
end
end
function PixelCanvas:drawCanvas180(canvas, x, y)
for cy = 1, canvas.height do
for cx = 1, canvas.width do
self:setPixel(x + cx - 1, y + canvas.height - cy - 1, canvas.canvas[cy][cx])
end
end
end
function PixelCanvas:drawCanvasRotated(canvas, cx, cy, angle)
local sin, cos = math.sin(angle), math.cos(angle)
local absSin, absCos = math.abs(sin), math.abs(cos)
local newWidth, newHeight = ceil(canvas.width * absCos + canvas.height * absSin), ceil(canvas.width * absSin + canvas.height * absCos)
for y = 1, newHeight do
for x = 1, newWidth do
local px, py = x - newWidth / 2, y - newHeight / 2
local rx, ry = px * cos - py * sin, px * sin + py * cos
rx, ry = rx + canvas.width / 2, ry + canvas.height / 2
rx, ry = round(rx), round(ry)
if rx >= 1 and rx <= canvas.width and ry >= 1 and ry <= canvas.height then
self:setPixel(x + cx - newWidth / 2, y + cy - newHeight / 2, canvas.canvas[ry][rx])
end
end
end
end
---Copies the contents of {canvas} into this canvas at {x, y} with color {c}.
---@param canvas PixelCanvas
---@param x integer
---@param y integer
---@param c integer
function PixelCanvas:drawTint(canvas, x, y, c)
for cy = 1, canvas.height do
for cx = 1, canvas.width do
if canvas.canvas[cy][cx] then
self:setPixel(x + cx - 1, y + cy - 1, c)
end
end
end
end
---Copies the contents of {canvas} into this canvas at {x, y} with the specified bounds.
---@param canvas PixelCanvas
---@param x integer
---@param y integer
---@param startX integer
---@param startY integer
---@param endX integer
---@param endY integer
function PixelCanvas:drawCanvasClip(canvas, x, y, startX, startY, endX, endY)
for cy = startY, endY do
for cx = startX, endX do
self:setPixel(x + cx - startX, y + cy - startY, canvas.canvas[cy][cx])
end
end
end
function PixelCanvas:drawRect(c, x, y, w, h)
for cy = 1, h do
for cx = 1, w do
self:setPixel(x + cx - 1, y + cy - 1, c)
end
end
end
---Mark the pixel at {x, y} as dirty, indicating that it should be updated during frame composition.
---@param x integer
---@param y integer
function PixelCanvas:mark(x, y)
x, y = floor(x), floor(y)
-- TODO: remove bounds checking?
if x < 1 or x > self.width or y < 1 or y > self.height then
return
end
self.dirty[y] = self.dirty[y] or {}
self.dirty[y][x] = true
end
---@param x integer
---@param y integer
---@param width integer
---@param height integer
function PixelCanvas:markRect(x, y, width, height)
x, y = floor(x), floor(y)
-- TODO: remove bounds checking?
for cy = y, y + height - 1 do
if cy < 1 or cy > self.height then
-- out of bounds
else
self.dirty[cy] = self.dirty[cy] or {}
for cx = x, x + width - 1 do
if cx < 1 or cx > self.width then
-- out of bounds
else
self.canvas[cy][cx] = nil
self.dirty[cy][cx] = true
end
end
end
end
end
---@param x integer
---@param y integer
---@param width integer
---@param height integer
function PixelCanvas:dirtyRect(x, y, width, height)
x, y = floor(x), floor(y)
-- TODO: remove bounds checking?
for cy = y, y + height - 1 do
if cy < 1 or cy > self.height then
-- out of bounds
else
self.dirty[cy] = self.dirty[cy] or {}
for cx = x, x + width - 1 do
if cx < 1 or cx > self.width then
-- out of bounds
else
self.dirty[cy][cx] = true
end
end
end
end
end
---Mark the rectangle formed by the given canvas at {x, y} as dirty.
---@param canvas PixelCanvas
---@param x integer
---@param y integer
function PixelCanvas:markCanvas(canvas, x, y)
for cy = 1, canvas.height do
for cx = 1, canvas.width do
self:setPixel(x + cx - 1, y + cy - 1, nil)
end
end
end
function PixelCanvas:markCanvasRotated(canvas, cx, cy, angle)
local sin, cos = math.sin(angle), math.cos(angle)
local absSin, absCos = math.abs(sin), math.abs(cos)
local newWidth, newHeight = ceil(canvas.width * absCos + canvas.height * absSin), ceil(canvas.width * absSin + canvas.height * absCos)
for y = 1, newHeight do
for x = 1, newWidth do
local px, py = x - newWidth / 2, y - newHeight / 2
local rx, ry = px * cos - py * sin, px * sin + py * cos
rx, ry = rx + canvas.width / 2, ry + canvas.height / 2
rx, ry = round(rx), round(ry)
if rx >= 1 and rx <= canvas.width and ry >= 1 and ry <= canvas.height then
self:setPixel(x + cx - newWidth / 2, y + cy - newHeight / 2, nil)
end
end
end
end
---Creates a new PixelCanvas with the same width and height as this canvas.
---@return PixelCanvas
function PixelCanvas:newFromSize()
return PixelCanvas.new(self.width, self.height)
end
function PixelCanvas.is(obj)
return getmetatable(obj) == PixelCanvas_mt
end
---@param others { [1]: PixelCanvas, [2]: integer, [3]: integer }[]
function PixelCanvas:composite(others)
for _, other in ipairs(others) do
self:drawCanvas(other[1], other[2], other[3])
end
end
---@class TextCanvas
---@field width number
---@field height number
---@field canvas { [integer]: { t: { [integer]: string }, c: { [integer]: string }, b: { [integer]: string } } }
---@field dirty { [integer]: { [integer]: boolean } }
---@operator call:PixelCanvas
local TextCanvas = {}
local TextCanvas_mt = { __index = TextCanvas }
setmetatable(TextCanvas, { __call = function(_, ...) return TextCanvas.new(...) end })
function TextCanvas.new(width, height)
local self = setmetatable({__opaque = true}, TextCanvas_mt)
self.width = width
self.height = height
self.brand = "TextCanvas"
self.canvas = {}
for y = 1, height do
self.canvas[y] = {}
self.canvas[y].t = {}
self.canvas[y].c = {}
self.canvas[y].b = {}
-- for x = 1, width do
-- [x] = nil
-- end
end
self.dirty = {} -- { [y] = { [x] = true } }
return self
end
---Write a string to the canvas at {x, y}.
---@param text string
---@param x integer
---@param y integer
---@param c integer
---@param b integer
function TextCanvas:write(text, x, y, c, b)
x, y = floor(x), floor(y)
if x < 1 or x > self.width or y < 1 or y > self.height then
return
end
c = _hex[c]
b = _hex[b]
self.dirty[y] = self.dirty[y] or {}
for i = 1, #text do
if x + i - 1 > self.width then
break
end
self.dirty[y][x + i - 1] = true
self.canvas[y].t[x + i - 1] = text:sub(i, i)
self.canvas[y].c[x + i - 1] = c
self.canvas[y].b[x + i - 1] = b
end
end
---Mark a string to the canvas at {x, y}.
---@param text string
---@param x integer
---@param y integer
function TextCanvas:markText(text, x, y)
x, y = floor(x), floor(y)
if x < 1 or x > self.width or y < 1 or y > self.height then
return
end
self.dirty[y] = self.dirty[y] or {}
for i = 1, #text do
if x + i - 1 > self.width then
break
end
self.dirty[y][x + i - 1] = true
self.canvas[y].t[x + i - 1] = nil
end
end
function TextCanvas.is(self)
return getmetatable(self) == TextCanvas_mt
end
---@class TeletextCanvas
---@field width integer
---@field height integer
---@field clear integer The color to use when a pixel is transparent.
---@field pixelCanvas PixelCanvas
---@field canvas { [integer]: { t: { [integer]: string }, c: { [integer]: string }, b: { [integer]: string }, direct: { [integer]: boolean } } }
---@field dirty { [integer]: { [integer]: boolean } }
---@field dirtyRows { [integer]: { [1]: integer, [2]: integer } }
---@operator call:TeletextCanvas
local TeletextCanvas = {}
local TeletextCanvas_mt = { __index = TeletextCanvas }
setmetatable(TeletextCanvas, { __call = function(_, ...) return TeletextCanvas.new(...) end })
function TeletextCanvas.new(clear, width, height)
local self = setmetatable({__opaque = true}, TeletextCanvas_mt)
self.width = width
self.height = height
self.clear = clear or colors.black
self.pixelCanvas = PixelCanvas(width*2, height*3)
self.canvas = {}
for y = 1, height do
self.canvas[y] = { t = {}, c = {}, b = {}, direct = {} }
for x = 1, width do
self.canvas[y].t[x] = " "
self.canvas[y].c[x] = "0"
self.canvas[y].b[x] = _hex[clear]
self.canvas[y].direct[x] = false
end
end
self.dirty = {} -- { [y] = { [x] = true } }
self.dirtyRows = {}
return self
end
function TeletextCanvas:reset(clear)
for y = 1, self.height do
self.canvas[y] = { t = {}, c = {}, b = {}, direct = {} }
for x = 1, self.width do
self.canvas[y].t[x] = " "
self.canvas[y].c[x] = "0"
self.canvas[y].b[x] = _hex[clear]
self.canvas[y].direct[x] = false
end
end
self.dirty = {} -- { [y] = { [x] = true } }
self.dirtyRows = {}
end
---Composites the given pixel canvases onto the teletext's internal
---pixel canvas and recomputes the teletext's character data.
---@param ... { [1]: PixelCanvas, [2]: integer, [3]: integer }
function TeletextCanvas:composite(...)
local others = {...}
-- Partition the screen based on canvas x/y/w/h
-- local t1 = os.epoch("utc")
local partitionSize = 20
local partitions = {}
for y = 1, self.height*3, partitionSize do
partitions[ceil(y/partitionSize)] = {}
for x = 1, self.width*2, partitionSize do
partitions[ceil(y/partitionSize)][ceil(x/partitionSize)] = {}
end
end
-- local t2 = os.epoch("utc")
-- print("Partitioning took " .. (t2-t1) .. "ms")
-- t1 = os.epoch("utc")
for _, other in ipairs(others) do
other[2] = floor(other[2])
other[3] = floor(other[3])
-- if PixelCanvas.is(other) then
local otherCanvas, otherX, otherY = other[1], other[2], other[3]
local originPartitionX = (ceil(otherX/partitionSize) - 1)*partitionSize + 1
local originPartitionY = (ceil(otherY/partitionSize) - 1)*partitionSize + 1
--TODO: This is a hack, should use -1 instead of +1
for y = originPartitionY, otherY+otherCanvas.height+1, partitionSize do
for x = originPartitionX, otherX+otherCanvas.width+1, partitionSize do
local px = ceil(x/partitionSize)
local py = ceil(y/partitionSize)
local partition = (partitions[py] or {})[px]
if partition then
partition[#partition + 1] = other
end
end
end
-- end
end
-- t2 = os.epoch("utc")
-- print("Canvas partition assignment took " .. (t2-t1) .. "ms")
-- t1 = os.epoch("utc")
local queuedDirty = {}
local c = 0
for _, other in ipairs(others) do
local ocanvas, ox, oy = other[1], other[2]-1, other[3]-1
local isPixel = ocanvas.brand ~= "TextCanvas"
if ocanvas.allDirty then
for y = 1, ocanvas.height do
for x = 1, ocanvas.width do
if isPixel then
local tx = x+ox
local ty = y+oy
if tx >= 1 and tx <= self.width*2 and ty >= 1 and ty <= self.height*3 then
queuedDirty[ty] = queuedDirty[ty] or {}
queuedDirty[ty][tx] = true
c = c + 1
end
else
local tx = x*2-1 + ox
local ty = y*3-2 + oy
-- print(ty)
if tx >= 1 and tx <= self.width*2 and ty >= 1 and ty <= self.height*3 then
queuedDirty[ty] = queuedDirty[ty] or {}
queuedDirty[ty][tx] = true
c = c + 1
end
end
end
end
else
for y, row in pairs(ocanvas.dirty) do
for x, _ in pairs(row) do
if isPixel then
local tx = x+ox
local ty = y+oy
if tx >= 1 and tx <= self.width*2 and ty >= 1 and ty <= self.height*3 then
queuedDirty[ty] = queuedDirty[ty] or {}
queuedDirty[ty][tx] = true
c = c + 1
end
else
local tx = x*2-1 + ox
local ty = y*3-2 + oy
-- print(ty)
if tx >= 1 and tx <= self.width*2 and ty >= 1 and ty <= self.height*3 then
queuedDirty[ty] = queuedDirty[ty] or {}
queuedDirty[ty][tx] = true
c = c + 1
end
-- print(y)
-- for gayY = y-10,y+10 do
-- if gayY > 1 and gayY <= #ocanvas.dirty then
-- for gayX = x-10,x+10 do
-- if gayX > 1 and gayX <= #row then
-- queuedDirty[y*3] = queuedDirty[y*3] or {}
-- queuedDirty[y*3][x*2] = true
-- queuedDirty[y*3][x*2-1] = true
-- queuedDirty[y*3-1] = queuedDirty[y*3-1] or {}
-- queuedDirty[y*3-1][x*2] = true
-- queuedDirty[y*3-1][x*2-1] = true
-- queuedDirty[y*3-2] = queuedDirty[y*3-2] or {}
-- queuedDirty[y*3-2][x*2] = true
-- queuedDirty[y*3-2][x*2-1] = true
-- end
-- end
-- end
-- end
end
-- else
-- -- TODO: ewwwwwwww
-- end
end
end
end
ocanvas.dirty = {} -- TODO: is this necessary?
end
-- t2 = os.epoch("utc")
-- t1 = os.epoch("utc")
for y, row in pairs(queuedDirty) do
local targetY = ceil(y / 3)
self.dirty[targetY] = self.dirty[targetY] or {}
-- print("row size: " .. #row)
for x, _ in pairs(row) do
local targetX = ceil(x / 2)
local currPixel = self.pixelCanvas.canvas[y][x]
partitionY = ceil(y/partitionSize) --math.min(floor(y/partitionSize)+1, #partitions)
partitionX = ceil(x/partitionSize) --math.min(floor(x/partitionSize)+1, #partitions[1])
--print("getting partition (partizion size " .. partitionSize .. ") at x: " .. x .. ", y: " .. y .. " -> ".. floor(x/partitionSize)+1 .. ", " .. floor(y/partitionSize)+1)
--print("Partitions size: " .. #partitions[1] .. ", " .. #partitions)
local partition = partitions[partitionY][partitionX]
local found = false
-- local foundText = false
for i = #partition, 1, -1 do
local other = partition[i]
local otherCanvas, ox, oy = other[1], other[2], other[3]
if PixelCanvas.is(otherCanvas) then ---@cast other PixelCanvas
t1 = os.epoch("utc")
if otherCanvas.canvas[y-oy+1] then
local otherPixel = (otherCanvas.canvas[y-oy+1] or {})[x-ox+1]
if otherPixel then
found = true
if otherPixel ~= currPixel then
self.pixelCanvas.canvas[y][x] = otherPixel
self.dirty[targetY][targetX] = true
end
if self.canvas[targetY].direct[targetX] then
self.canvas[targetY].direct[targetX] = false
self.dirty[targetY][targetX] = true
end
break
end
end
t2 = os.epoch("utc")
else ---@cast other TextCanvas
-- print( targetX .. ", " .. targetY .. ": " .. ox .. ", " .. oy .. " -> " .. x .. ", " .. y)
local ty = ceil((y-oy+1)/3) --ceil(targetY - oy / 3)
local tx = ceil((x-ox+1)/2) --ceil(targetX - ox / 2)
-- print(tx .. " " .. ty)
-- if ty == 1 then
-- print("fuck")
-- print(#otherCanvas.canvas)
-- print(#otherCanvas.canvas[ty].t)
-- sleep(1)
-- end
if otherCanvas.canvas[ty] and otherCanvas.canvas[ty].c[tx] then
-- print("found text")
local otherRow = otherCanvas.canvas[ty]
local otherT = otherRow.t[tx]
local otherC = otherRow.c[tx]
local otherB = otherRow.b[tx]
if otherT then
found = true
foundText = true
local currRow = self.canvas[targetY]
local currT = currRow.t[targetX]
local currC = currRow.c[targetX]
local currB = currRow.b[targetX]
if otherT ~= currT or otherC ~= currC or otherB ~= currB then
currRow.t[targetX] = otherT
currRow.c[targetX] = otherC
currRow.b[targetX] = otherB
currRow.direct[targetX] = true
self.dirty[targetY][targetX] = false -- Already processed
local dirtyRow = self.dirtyRows[targetY] or {}
local minX, maxX = dirtyRow[1], dirtyRow[2]
minX = minX and min(minX, targetX) or targetX
maxX = maxX and max(maxX, targetX) or targetX
self.dirtyRows[targetY] = { minX, maxX }
end
break
else
local currRow = self.canvas[targetY]
currRow.direct[targetX] = false
end
else
-- local currRow = self.canvas[targetY]
-- currRow.direct[targetX] = false
end
end
end
if not found then
self.pixelCanvas.canvas[y][x] = self.clear
self.dirty[targetY][targetX] = true
self.canvas[targetY].direct[targetX] = false
end
-- if not foundText then
-- if self.canvas[targetY].direct[targetX] then
-- self.canvas[targetY].direct[targetX] = false
-- self.dirty[targetY][targetX] = true
-- end
-- end
end
end
-- t2 = os.epoch("utc")
-- print("Canvas merging took " .. (t2-t1) .. "ms")
-- Recalculate teletext canvas
local clear = self.clear
for y, row in pairs(self.dirty) do
local oy = (y - 1) * 3
local dirtyRow = self.dirtyRows[y] or {}
local minX, maxX = dirtyRow[1], dirtyRow[2]
for x, _ in pairs(row) do
minX = minX and min(minX, x) or x
maxX = maxX and max(maxX, x) or x
local ox = (x - 1) * 2
local sub, char, c1, c2, c3, c4, c5, c6 = 32768, 1,
self.pixelCanvas.canvas[oy + 1][ox + 1] or clear,
self.pixelCanvas.canvas[oy + 1][ox + 2] or clear,
self.pixelCanvas.canvas[oy + 2][ox + 1] or clear,
self.pixelCanvas.canvas[oy + 2][ox + 2] or clear,
self.pixelCanvas.canvas[oy + 3][ox + 1] or clear,
self.pixelCanvas.canvas[oy + 3][ox + 2] or clear
if c1 ~= c6 then
sub = c1
char = 2
end
if c2 ~= c6 then
sub = c2
char = char + 2
end
if c3 ~= c6 then
sub = c3
char = char + 4
end
if c4 ~= c6 then
sub = c4
char = char + 8
end
if c5 ~= c6 then
sub = c5
char = char + 16
end
if self.canvas[y].direct[x] == false then
self.canvas[y].t[x] = _ttxChars[char]
self.canvas[y].c[x] = _hex[sub]
self.canvas[y].b[x] = _hex[c6]
end
end
self.dirtyRows[y] = { minX, maxX }
end
end
---Output any dirty rows to the given {out} terminal.
---@param out Terminal
function TeletextCanvas:outputDirty(out)
for y, row in pairs(self.dirtyRows) do
local minX, maxX = row[1], row[2]
if minX then
local t,c,b
if maxX - minX == 0 then
t = self.canvas[y].t[minX]
c = self.canvas[y].c[minX]
b = self.canvas[y].b[minX]
else
t = concat(self.canvas[y].t, "", minX, maxX)
c = concat(self.canvas[y].c, "", minX, maxX)
b = concat(self.canvas[y].b, "", minX, maxX)
end
out.setCursorPos(minX, y)
out.blit(t, c, b)
end
end
self.dirty = {}
self.dirtyRows = {}
end
---Output the entire canvas to the given {out} terminal.
---@param out Terminal
function TeletextCanvas:outputFlush(out)
for y = 1, self.height do
local t = concat(self.canvas[y].t, "")
local c = concat(self.canvas[y].c, "")
local b = concat(self.canvas[y].b, "")
out.setCursorPos(1, y)
out.blit(t, c, b)
end
end
return {
PixelCanvas = PixelCanvas,
TeletextCanvas = TeletextCanvas,
TextCanvas = TextCanvas,
}
end
preload["modules.animation"] = function(...)
--[[
MIT License
Copyright (c) 2022 emmachase
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 Iter = require("util.iter")
local list = Iter.list
local Ease = require("modules.animation.Ease")
local function copy(t)
local new = {}
for k, v in pairs(t) do
new[k] = v
end
return new
end
---@alias Step { duration: number, to: table?, easing: function | table<string, function> | nil }
---@class AnimationDescriptor
---@field sprite PixelCanvas
---@field initial table
---@field steps Step[]?
---@alias AnimationSets AnimationDescriptor[][]
---Evaluate one animation descriptor and return current animation state.
---@param animation AnimationDescriptor
---@param t number
---@return table animationState, number consumedTime, boolean isFinished
local function evaluateSingleAnimation(animation, t)
local state = copy(animation.initial)
state.sprite = animation.sprite
local steps = animation.steps
if not steps then
return state, 0, true
end
local stepIndex = 1
local consumedTime = 0
while t > 0 do
local step = steps[stepIndex]
if not step then break end
if t > step.duration then -- If this step is already completed, just skip it and set the to values
if step.to then
for k, v in pairs(step.to) do
state[k] = v
end
end
t = t - step.duration
consumedTime = consumedTime + step.duration
stepIndex = stepIndex + 1
else
-- If this step is not completed, interpolate the values
if step.to then
local easingFunction = step.easing or Ease.linear
for k, v in pairs(step.to) do
if type(easingFunction) == "function" then
state[k] = easingFunction(t, state[k], v - state[k], step.duration)
else ---@cast easingFunction table
state[k] = (easingFunction[k] or Ease.linear)(t, state[k], v - state[k], step.duration)
end
end
end
consumedTime = consumedTime + t
break
end
end
return state, consumedTime, stepIndex > #steps
end
---Evaluate all animation descriptors for iterative sets and return current animation state.
---@param animationSets AnimationSets
---@param t number
---@return { sprite: PixelCanvas, [string]: number }[] visibleSprites, boolean isFinished
local function evaluateAnimationSets(animationSets, t)
local remainingTime = t
local setContents = {}
for animationSet in list(animationSets) do
local setDuration = 0
local allFinished = true
setContents = {}
for i = 1, #animationSet do
local animationState, consumedTime, finished = evaluateSingleAnimation(animationSet[i], remainingTime)
setContents[i] = animationState
print("c", consumedTime, finished)
setDuration = math.max(setDuration, consumedTime)
if not finished then
allFinished = false
end
end
if allFinished then
remainingTime = remainingTime - setDuration
else
return setContents, false -- If we have no time left, return the current set
end
end
return setContents, true -- All sets are finished
end
---@param animationSets AnimationSets
---@return { sprite: PixelCanvas, [string]: number }[] visibleSprites
local function skipAnimation(animationSets)
local setContents = {}
local animationSet = animationSets[#animationSets]
for i = 1, #animationSet do
setContents[i] = evaluateSingleAnimation(animationSet[i], math.huge)
end
return setContents
end
return {
evaluateSingleAnimation = evaluateSingleAnimation,
evaluateAnimationSets = evaluateAnimationSets,
skipAnimation = skipAnimation,
}
end
preload["modules.animation.Ease"] = function(...)
--[[
MIT License
Copyright (c) 2022 emmachase
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 Ease = {}
function Ease.linear(t, b, c, d)
return c * t / d + b
end
function Ease.inQuad(t, b, c, d)
t = t / d
return c * t * t + b
end
function Ease.outQuad(t, b, c, d)
t = t / d
return -c * t * (t - 2) + b
end
function Ease.inOutQuad(t, b, c, d)
t = t / d * 2
if t < 1 then
return c / 2 * t * t + b
else
t = t - 1
return -c / 2 * (t * (t - 2) - 1) + b
end
end
function Ease.outInQuad(t, b, c, d)
if t < d / 2 then
return Ease.outQuad(t * 2, b, c / 2, d)
else
return Ease.inQuad((t * 2) - d, b + c / 2, c / 2, d)
end
end
function Ease.inCubic(t, b, c, d)
t = t / d
return c * t * t * t + b
end
function Ease.outCubic(t, b, c, d)
t = t / d - 1
return c * (t * t * t + 1) + b
end
function Ease.inOutCubic(t, b, c, d)
t = t / d * 2
if t < 1 then
return c / 2 * t * t * t + b
else
t = t - 2
return c / 2 * (t * t * t + 2) + b
end
end
function Ease.outInCubic(t, b, c, d)
if t < d / 2 then
return Ease.outCubic(t * 2, b, c / 2, d)
else
return Ease.inCubic((t * 2) - d, b + c / 2, c / 2, d)
end
end
function Ease.inQuart(t, b, c, d)
t = t / d
return c * t * t * t * t + b
end
function Ease.outQuart(t, b, c, d)
t = t / d - 1
return -c * (t * t * t * t - 1) + b
end
function Ease.inOutQuart(t, b, c, d)
t = t / d * 2
if t < 1 then
return c / 2 * t * t * t * t + b
else
t = t - 2
return -c / 2 * (t * t * t * t - 2) + b
end
end
function Ease.outInQuart(t, b, c, d)
if t < d / 2 then
return Ease.outQuart(t * 2, b, c / 2, d)
else
return Ease.inQuart((t * 2) - d, b + c / 2, c / 2, d)
end
end
return Ease
end
preload["fonts.smolfont"] = function(...)
local loadRIF = require("modules.rif")
local createFont = require("modules.font")
local smolFontSheet = loadRIF("res.smolfont")
local smolFont = createFont(smolFontSheet, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:;<=>?[\\]^_{|}~\128` !\"#$%&'()*+,-./@\164")
return smolFont
end
preload["fonts.bigfont"] = function(...)
local loadRIF = require("modules.rif")
local createFont = require("modules.font")
local bigFontSheet = loadRIF("res.cfont")
local bigFont = createFont(bigFontSheet, " abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/-,.\164!:\6@'<>")
return bigFont
end
preload["core.schemas"] = function(...)
local configSchema = {
branding = {
title = "string",
subtitle = "string?",
},
settings = {
hideUnavailableProducts = "boolean",
hideNegativePrices = "boolean",
pollFrequency = "number",
categoryCycleFrequency = "number",
activityTimeout = "number",
dropDirection = "enum<'forward' | 'up' | 'down' | 'north' | 'south' | 'east' | 'west'>: direction",
smallTextKristPayCompatability = "boolean",
playSounds = "boolean",
showFooter = "boolean",
refundInvalidMetaname = "boolean",
refundMissingMetaname = "boolean",
refundInsufficentFunds = "boolean"
},
lang = {
footer = "string",
footerNoName = "string?",
refundRemaining = "string",
refundOutOfStock = "string",
refundAtLeastOne = "string",
refundInvalidProduct = "string",
refundNoProduct = "string",
refundError = "string"
},
theme = {
formatting = {
headerAlign = "enum<'left' | 'center' | 'right'>: alignment",
subtitleAlign = "enum<'left' | 'center' | 'right'>: alignment",
footerAlign = "enum<'left' | 'center' | 'right'>: alignment",
footerSize = "enum<'small' | 'medium' | 'large' | 'auto'>: size",
productNameAlign = "enum<'left' | 'center' | 'right'>: alignment",
layout = "enum<'small' | 'medium' | 'large' | 'auto' | 'custom'>: layout",
layoutFile = "file?"
},
colors = {
bgColor = "color",
headerBgColor = "color",
headerColor = "color",
subtitleBgColor = "color",
subtitleColor = "color",
footerBgColor = "color",
footerColor = "color",
productBgColors = {
__type = "array",
__min = 1,
__entry = "color"
},
outOfStockQtyColor = "color",
lowQtyColor = "color",
warningQtyColor = "color",
normalQtyColor = "color",
productNameColor = "color",
outOfStockNameColor = "color",
priceColor = "color",
addressColor = "color",
currencyTextColor = "color",
currencyBgColors = {
__type = "array",
__min = 1,
__entry = "color"
},
catagoryTextColor = "color",
categoryBgColors = {
__type = "array",
__min = 1,
__entry = "color"
},
activeCategoryColor = "color",
},
palette = {
[colors.black] = "number",
[colors.blue] = "number",
[colors.purple] = "number",
[colors.green] = "number",
[colors.brown] = "number",
[colors.gray] = "number",
[colors.lightGray] = "number",
[colors.red] = "number",
[colors.orange] = "number",
[colors.yellow] = "number",
[colors.lime] = "number",
[colors.cyan] = "number",
[colors.magenta] = "number",
[colors.pink] = "number",
[colors.lightBlue] = "number",
[colors.white] = "number"
}
},
terminalTheme = {
colors = {
titleTextColor = "color",
titleBgColor = "color",
bgColor = "color",
catagoryTextColor = "color",
catagoryBgColor = "color",
activeCatagoryBgColor = "color",
logTextColor = "color",
configEditor = {
bgColor = "color",
textColor = "color",
buttonColor = "color",
buttonTextColor = "color",
inactiveButtonColor = "color",
inactiveButtonTextColor = "color",
scrollbarBgColor = "color",
scrollbarColor = "color",
inputBgColor = "color",
inputTextColor = "color",
errorBgColor = "color",
errorTextColor = "color",
toggleColor = "color",
toggleBgColor = "color",
toggleOnColor = "color",
toggleOffColor = "color",
unsavedChangesColor = "color",
unsavedChangesTextColor = "color",
modalBgColor = "color",
modalTextColor = "color",
modalBorderColor = "color",
},
productsEditor = {
bgColor = "color",
textColor = "color",
buttonColor = "color",
buttonTextColor = "color",
inactiveButtonColor = "color",
inactiveButtonTextColor = "color",
scrollbarBgColor = "color",
scrollbarColor = "color",
inputBgColor = "color",
inputTextColor = "color",
errorBgColor = "color",
errorTextColor = "color",
toggleColor = "color",
toggleBgColor = "color",
toggleOnColor = "color",
toggleOffColor = "color",
unsavedChangesColor = "color",
unsavedChangesTextColor = "color",
modalBgColor = "color",
modalTextColor = "color",
modalBorderColor = "color",
}
},
palette = {
[colors.black] = "number",
[colors.blue] = "number",
[colors.purple] = "number",
[colors.green] = "number",
[colors.brown] = "number",
[colors.gray] = "number",
[colors.lightGray] = "number",
[colors.red] = "number",
[colors.orange] = "number",
[colors.yellow] = "number",
[colors.lime] = "number",
[colors.cyan] = "number",
[colors.magenta] = "number",
[colors.pink] = "number",
[colors.lightBlue] = "number",
[colors.white] = "number"
}
},
sounds = {
button = "sound",
purchase = "sound",
},
currencies = {
__type = "array",
__label = "id",
__min = 1,
__entry = {
id = "string",
node = "string?",
name = "regex<^[a-z0-9]{1,64}(\\.[a-z0-9]{1,64})?$>?: name",
pkey = "string",
pkeyFormat = "enum<'raw' | 'kristwallet'>: pkey format",
value = "number?"
}
},
peripherals = {
monitor = "string?",
speaker = "speaker?",
modem = "modem?",
shopSyncModem = "modem?",
blinker = "enum<'left' | 'right' | 'front' | 'back' | 'top' | 'bottom'>?: side",
exchangeChest = "chest?",
outputChest = "chest",
},
shopSync = {
enabled = "boolean?",
name = "string?",
description = "string?",
owner = "string?",
location = {
description = "string?",
dimension = "enum<'overworld' | 'nether' | 'end'>?: dimension"
}
},
exchange = {
enabled = "boolean",
node = "string"
}
}
local productsSchema = {
__type = "array",
__label = "name",
__entry = {
modid = "string?",
productId = "string?",
name = "string",
address = "string",
category = "string?",
hidden = "boolean?",
maxQuantity = "number?",
price = "number",
priceOverrides = {
__type = "array?",
__label = "currency",
__entry = {
currency = "string",
price = "number"
}
},
bundle = {
__type = "array?",
__label = "product",
__entry = {
product = "string",
quantity = "number"
}
},
predicate = "table?"
}
}
local hooksSchema = {
start = "function?",
prePurchase = "function?",
purchase = "function?",
failedPurchase = "function?",
programError = "function?",
blink = "function?",
configSaved = "function?",
productsSaved = "function?",
}
local soundSchema = {
name = "string",
volume = "number",
pitch = "number"
}
return {
configSchema = configSchema,
productsSchema = productsSchema,
hooksSchema = hooksSchema,
soundSchema = soundSchema
}
end
preload["core.inventory.ScanInventory"] = function(...)
local eventHook = require("util.eventHook")
local score = require("util.score")
local itemCache = {}
local nbtHashCache = {}
local partialObjectMatches
local function partialArrayMatches(partialArray, array)
for i = 1, #partialArray do
local found = false
for j = 1, #array do
if type(array[j]) == "table" then
if partialObjectMatches(partialArray[i], array[j]) then
found = true
break
end
elseif partialArray[i] == array[j] then
found = true
break
end
end
if not found then
return false
end
end
return true
end
function partialObjectMatches(partialObject, object)
if type(object) ~= "table" then
return false
end
if object[1] then
return partialArrayMatches(partialObject, object)
end
for k,v in pairs(partialObject) do
if type(v) == "table" then
if not partialObjectMatches(v, object[k]) then
return false
end
elseif object[k] == nil or object[k] ~= v then
return false
end
end
return true
end
local function predicateMatches(predicates, item, allowCached)
if not allowCached or not item.cachedMeta then
local meta = peripheral.call(item.inventory, "getItemDetail", item.slot)
item.cachedMeta = meta
end
return partialObjectMatches(predicates, item.cachedMeta)
end
local function findMatchingProducts(products, item)
local matchingProducts = {}
for i = 1, #products do
local product = products[i]
if product.predicates and not product.predicatesString then
product.predicatesString = textutils.serialize(product.predicates)
end
if item.name == product.modid then
if product.predicates and item.nbt and nbtHashCache[item.name .. "." .. item.nbt] then
for j = 1, #nbtHashCache[item.name .. "." .. item.nbt] do
if nbtHashCache[item.name .. "." .. item.nbt][j] == product.predicatesString then
table.insert(matchingProducts, product)
break
end
end
else
if not product.predicates or predicateMatches(product.predicates, item, true) then
table.insert(matchingProducts, product)
end
end
end
end
if item.nbt and not nbtHashCache[item.name .. "." .. item.nbt] then
nbtHashCache[item.name .. "." .. item.nbt] = {}
for i = 1, #matchingProducts do
table.insert(nbtHashCache[item.name .. "." .. item.nbt], matchingProducts[i].predicatesString)
end
end
return matchingProducts
end
local function getInventories()
peripherals = peripheral.getNames()
inventories = {}
for i = 1, #peripherals do
name = peripherals[i]
local methods = peripheral.getMethods(name)
local hasListItems = false
local hasPushItems = false
if methods then
for j = 1, #methods do
if methods[j] == "list" then
hasListItems = true
end
if methods[j] == "pushItems" then
hasPushItems = true
end
if hasListItems and hasPushItems then
break
end
end
end
if hasListItems and hasPushItems then
table.insert(inventories, peripheral.wrap(name))
end
end
return inventories
end
local function getInventoryItems(inventory, products)
local inventoryName = peripheral.getName(inventory)
local items = {}
local slots = inventory.list()
for slot, item in pairs(slots) do
if item then
item.inventory = inventoryName
item.slot = slot
table.insert(items, item)
local matchingProducts = findMatchingProducts(products, item)
for j = 1, #matchingProducts do
local product = matchingProducts[j]
if not product.newQty then
product.newQty = 0
end
product.newQty = product.newQty + item.count
end
end
end
return items
end
local function getAllInventoryItems(inventories, products)
local items = {}
local inventoryThreads = {}
for i = 1, #inventories do
local inventory = inventories[i]
table.insert(inventoryThreads, function()
local inventoryItems = getInventoryItems(inventory, products)
for j = 1, #inventoryItems do
table.insert(items, inventoryItems[j])
end
end)
end
parallel.waitForAll(unpack(inventoryThreads))
return items
end
local function updateProductInventory(products, onInventoryRefresh)
local quantitiesChanged = false
for i = 1, #products do
products[i].newQty = 0
end
local inventories = getInventories()
local originalProducts = products
products = score.copyDeep(originalProducts)
local items = getAllInventoryItems(inventories, products)
itemCache = items
if onInventoryRefresh then
eventHook.execute(onInventoryRefresh, products, items)
end
for i = 1, #products do
local product = products[i]
product.predicatesString = nil
local oldQty = product.quantity
product.quantity = product.newQty
if product.bundle and #product.bundle > 0 then
if not product.modid then
product.quantity = nil
end
for _, bundledProduct in ipairs(product.bundle) do
for _, searchProduct in ipairs(products) do
if searchProduct.address:lower() == bundledProduct.product:lower() or searchProduct.name:lower() == bundledProduct.product:lower() or (searchProduct.productId and searchProduct.productId:lower() == bundledProduct.product:lower()) then
local searchQty = searchProduct.newQty
if not searchQty then
searchQty = searchProduct.quantity
end
if not product.quantity then
product.quantity = searchQty
end
product.quantity = math.min(product.quantity, math.min(searchQty / bundledProduct.quantity))
end
end
end
end
if product.quantity ~= oldQty then
quantitiesChanged = true
end
product.newQty = nil
product.__opaque = true
originalProducts[i] = product
end
return quantitiesChanged
end
local function getItemCache()
return itemCache
end
local function findProductItemsFrom(product, quantity, items, cached)
local sources = {}
local remaining = quantity
if product.predicates and not product.predicatesString then
product.predicatesString = textutils.serialize(product.predicates)
end
for i = 1, #items do
local item = items[i]
local inventory = item.inventory
local slot = item.slot
local cacheHit = false
if item.nbt and nbtHashCache[item.name .. "." .. item.nbt] then
for j = 1, #nbtHashCache[item.name .. "." .. item.nbt] do
if nbtHashCache[item.name .. "." .. item.nbt][j] == product.predicatesString then
cacheHit = true
break
end
end
end
if item.name == product.modid and (not cached or not product.predicates or cacheHit or (item.cachedMeta and partialObjectMatches(product.predicates, item.cachedMeta))) then
if cached or product.predicates then
item = peripheral.call(inventory, "getItemDetail", slot)
end
if item then
if item.name ~= product.modid or (product.predicates and not partialObjectMatches(product.predicates, item)) then
item = nil
else
item.inventory = inventory
item.slot = slot
end
end
if item and item.count > 0 then
local amount = math.min(item.count, remaining)
table.insert(sources, {
inventory = item.inventory,
slot = item.slot,
amount = amount
})
remaining = remaining - amount
end
end
if remaining <= 0 then
break
end
end
return sources, quantity - remaining
end
local function findProductItems(products, product, quantity, onInventoryRefresh)
local sources = nil
local amount = 0
local items = getItemCache()
sources, amount = findProductItemsFrom(product, quantity, items, true)
if amount == 0 then
updateProductInventory(products, onInventoryRefresh)
items = getItemCache()
sources, amount = findProductItemsFrom(product, quantity, items)
end
for i = 1, #products do
products[i].predicatesString = nil
end
return sources, amount
end
function clearNbtCache()
nbtHashCache = {}
end
return {
updateProductInventory = updateProductInventory,
getItemCache = getItemCache,
findProductItems = findProductItems,
clearNbtCache = clearNbtCache
}
end
preload["core.ShopState"] = function(...)
local Krypton = require("Krypton")
local ScanInventory = require("core.inventory.ScanInventory")
local Pricing = require("core.Pricing")
local sound = require("util.sound")
local score = require("util.score")
local eventHook = require("util.eventHook")
local blinkFrequency = 3
local shopSyncFrequency = 30
local shopSyncChannel = 9773
---@class ShopState
---@field running boolean
local ShopState = {}
local ShopState_mt = { __index = ShopState }
function ShopState.new(config, products, peripherals, version, logs, eventHooks)
local self = setmetatable({}, ShopState_mt)
self.running = false
self.config = config
self.peripherals = peripherals
self.products = products
self.version = version
if config.currencies and config.currencies[1] then
self.selectedCurrency = config.currencies[1]
else
self.selectedCurrency = nil
end
self.selectedCategory = 1
self.numCategories = 1
self.productsChanged = false
self.logs = logs
self.eventHooks = eventHooks
self.lastTouched = os.epoch("utc")
return self
end
local function waitForAnimation(uid)
coroutine.yield("animationFinished", uid)
end
local function parseMeta(transactionMeta)
local meta = {}
local i = 1
if transactionMeta and #transactionMeta > 0 then
for metaEntry in transactionMeta:gmatch("([^;]+)") do
if metaEntry:find("=") then
local key, value = metaEntry:match("([^=]+)=([^=]+)")
meta[key] = value
else
meta[metaEntry] = true
meta[i] = metaEntry
i = i + 1
end
end
end
return meta
end
local function validateReturnAddress(address)
-- Primitive validation, will accept all valid addresses at the expense of some false positives
if address:find("@") then
local metaname, name = address:match("([^@]+)@([^@]+).%w+")
if not metaname or not name then
return false
end
if #name > 64 or #metaname > 32 then
return false
end
else
if #address > 64 then
return false
end
end
return true
end
local function isRelative(name)
return name == "top" or name == "bottom" or name == "left" or name == "right" or name == "front" or name == "back"
end
local function refund(currency, address, meta, value, message, error)
message = message or "Here is your refund!"
local returnTo = address
if meta and meta["return"] then
if validateReturnAddress(meta["return"]) then
returnTo = meta["return"]
end
end
if not error then
currency.krypton.ws:makeTransaction(returnTo, value, "message=" .. message)
else
currency.krypton.ws:makeTransaction(returnTo, value, "error=" .. message)
end
end
local function hasProductsChanged(original, new)
if type(original) ~= type(new) then
return true
end
if type(original) ~= "table" then
return original ~= new
end
if #original ~= #new then
return true
end
for k, v in pairs(original) do
if k ~= "quantity" then
if new[k] == nil then
return true
end
if hasProductsChanged(v, new[k]) then
return true
end
end
end
for k, v in pairs(new) do
if original[k] == nil then
return true
end
end
return false
end
function ShopState:handlePurchase(transaction, meta, sentMetaname, transactionCurrency)
local purchasedProduct = nil
if self.eventHooks and self.eventHooks.preProduct then
purchasedProduct, err, errorMessage = eventHook.execute(self.eventHooks.preProduct, transaction, transactionCurrency, meta, sentMetaname, self.products)
if err then
refund(transactionCurrency, transaction.from, meta, transaction.value, errorMessage or self.config.lang.refundDenied, true)
if self.eventHooks and self.eventHooks.failedPurchase then
eventHook.execute(self.eventHooks.failedPurchase, transaction, transactionCurrency, purchasedProduct, errorMessage or self.config.lang.refundDenied)
end
return
end
end
if purchasedProduct == nil then
for _, product in ipairs(self.products) do
if product.address:lower() == sentMetaname:lower() or product.name:gsub(" ", ""):lower() == sentMetaname:lower() then
purchasedProduct = product
break
end
end
end
if not purchasedProduct then
if self.config.settings.refundInvalidMetaname then
refund(transactionCurrency, transaction.from, meta, transaction.value, self.config.lang.refundInvalidProduct, true)
end
if self.eventHooks and self.eventHooks.failedPurchase then
eventHook.execute(self.eventHooks.failedPurchase, transaction, transactionCurrency, nil, self.config.lang.refundInvalidProduct)
end
return
end
local productPrice = Pricing.getProductPrice(purchasedProduct, transactionCurrency)
local amountPurchased = math.floor(transaction.value / productPrice)
if productPrice == 0 then
amountPurchased = math.max(transaction.value, 1)
end
if purchasedProduct.maxQuantity then
amountPurchased = math.min(amountPurchased, purchasedProduct.maxQuantity)
end
if amountPurchased <= 0 then
if self.config.settings.refundInsufficentFunds then
refund(transactionCurrency, transaction.from, meta, transaction.value, self.config.lang.refundAtLeastOne, true)
end
if self.eventHooks and self.eventHooks.failedPurchase then
eventHook.execute(self.eventHooks.failedPurchase, transaction, transactionCurrency, purchasedProduct, self.config.lang.refundAtLeastOne)
end
return
end
local productsPurchased = {}
if purchasedProduct.modid then
table.insert(productsPurchased, { product = purchasedProduct, quantity = 1 })
end
if purchasedProduct.bundle and #purchasedProduct.bundle > 0 then
for _, bundleProduct in ipairs(purchasedProduct.bundle) do
for _, product in ipairs(self.products) do
if product.address:lower() == bundleProduct.product:lower() or product.name:lower() == bundleProduct.product:lower() or (product.productId and product.productId:lower() == bundleProduct.product:lower()) then
local productFound = false
for _, productPurchased in ipairs(productsPurchased) do
if productPurchased.product == product then
productPurchased.quantity = productPurchased.quantity + bundleProduct.quantity
productFound = true
break
end
end
if not productFound then
table.insert(productsPurchased, { product = product, quantity = bundleProduct.quantity })
end
break
end
end
end
end
if self.eventHooks and self.eventHooks.preStockCheck then
local allowPurchase, err, errMessage, invisible = eventHook.execute(self.eventHooks.preStockCheck, transaction, productsPurchased, self.products, amountPurchased)
if allowPurchase == false then
if not invisible then
refund(transactionCurrency, transaction.from, meta, transaction.value, errMessage or self.config.lang.refundDenied, err)
if self.eventHooks and self.eventHooks.failedPurchase then
eventHook.execute(self.eventHooks.failedPurchase, transaction, transactionCurrency, purchasedProduct, errMessage or self.config.lang.refundDenied, err)
end
end
return
end
end
local available = amountPurchased
for _, productPurchased in ipairs(productsPurchased) do
local onInventoryRefresh
if self.eventHooks and self.eventHooks.onInventoryRefresh then
onInventoryRefresh = self.eventHooks.onInventoryRefresh
end
local productSources, productAvailable = ScanInventory.findProductItems(self.products, productPurchased.product, productPurchased.quantity * amountPurchased, onInventoryRefresh)
available = math.min(available, math.floor(productAvailable / productPurchased.quantity))
productPurchased.sources = productSources
if available == 0 then
break
end
end
if available <= 0 then
refund(transactionCurrency, transaction.from, meta, transaction.value, self.config.lang.refundOutOfStock)
if self.eventHooks and self.eventHooks.failedPurchase then
eventHook.execute(self.eventHooks.failedPurchase, transaction, transactionCurrency, purchasedProduct, self.config.lang.refundOutOfStock)
end
return
end
local currencyMinimumIncrement = transactionCurrency.minimumIncrement or 1
local refundAmountRaw = transaction.value - (available * productPrice)
local refundAmount = math.floor(refundAmountRaw / currencyMinimumIncrement) * currencyMinimumIncrement
local allowPurchase = true
local err
local errMessage
if self.eventHooks and self.eventHooks.prePurchase then
allowPurchase, err, errMessage, invisible = eventHook.execute(self.eventHooks.prePurchase, purchasedProduct, available, refundAmount, transaction, transactionCurrency)
end
if allowPurchase == false then
if not invisible then
refund(transactionCurrency, transaction.from, meta, transaction.value, errMessage or self.config.lang.refundDenied, err)
if self.eventHooks and self.eventHooks.failedPurchase then
eventHook.execute(self.eventHooks.failedPurchase, transaction, transactionCurrency, purchasedProduct, errMessage or self.config.lang.refundDenied, err)
end
end
return
end
print("Purchased " .. available .. " of " .. purchasedProduct.name .. " for " .. transaction.from .. " for " .. transaction.value .. " " .. transactionCurrency.id .. " (refund " .. refundAmount .. ")")
for _, productPurchased in ipairs(productsPurchased) do
for _, productSource in ipairs(productPurchased.sources) do
if self.config.peripherals.outputChest == "self" then
if not turtle then
error("Self output but not a turtle!")
end
if not self.peripherals.modem.getNameLocal() then
error("Modem is not connected! Try right clicking it")
end
if turtle.getSelectedSlot() ~= 1 then
turtle.select(1)
end
peripheral.call(productSource.inventory, "pushItems", self.peripherals.modem.getNameLocal(), productSource.slot, productSource.amount, 1)
if self.config.settings.dropDirection == "forward" then
turtle.drop(productSource.amount)
elseif self.config.settings.dropDirection == "up" then
turtle.dropUp(productSource.amount)
elseif self.config.settings.dropDirection == "down" then
turtle.dropDown(productSource.amount)
else
error("Invalid drop direction: " .. self.config.settings.dropDirection)
end
else
peripheral.call(productSource.inventory, "pushItems", self.config.peripherals.outputChest, productSource.slot, productSource.amount, 1)
--peripheral.call(state.config.peripherals.outputChest, "drop", 1, productSource.amount, state.config.settings.dropDirection)
end
end
productPurchased.product.quantity = productPurchased.product.quantity - (productPurchased.quantity * available)
end
if not purchasedProduct.modid then
purchasedProduct.quantity = math.max(0, purchasedProduct.quantity - available)
end
if refundAmount > 0 then
refund(transactionCurrency, transaction.from, meta, refundAmount, self.config.lang.refundRemaining)
end
if self.config.settings.playSounds then
sound.playSound(self.peripherals.speaker, self.config.sounds.purchase)
end
if self.eventHooks and self.eventHooks.purchase then
eventHook.execute(self.eventHooks.purchase, purchasedProduct, available, refundAmount, transaction, transactionCurrency)
end
os.queueEvent("radon_shopsync_update")
end
function ShopState:resolveCarrotPayName(name)
-- Hardcoded endpoint as any future alternative name system is recommended to use the krist name API schema
local node = "https://carrotpay.herrkatze.com/v2/"
local endpoint = "names/" .. textutils.urlEncode(name) .. ".crt"
local url = node .. endpoint
local response, err = http.get(url)
if not response then
error(err, 3)
end
local body = response.readAll()
response.close()
local data = textutils.unserializeJSON(body)
if not data.ok then
if (data.error ~= "invalid_parameter") then
error(data.error, 3)
else
error({data.error, data.parameter}, 3)
end
end
return data
end
function ShopState:setupKrypton()
self.selectedCurrency = self.config.currencies[1]
self.currencies = {}
self.kryptonListeners = {}
for _, currency in ipairs(self.config.currencies) do
if currency.name == "" then
currency.name = nil
end
local node = currency.node
if currency.id == "krist" or currency.id == "carrotpay" then
node = node or "https://krist.dev/"
currency.minimumIncrement = currency.minimumIncrement or 1
elseif currency.id == "tenebra" then
node = node or "https://tenebra.lil.gay/"
currency.minimumIncrement = currency.minimumIncrement or 1
elseif currency.id == "kromer" then
node = node or "https://kromer.reconnected.cc/api/krist/"
currency.minimumIncrement = currency.minimumIncrement or 0.01
end
currency.krypton = Krypton.new({
privateKey = currency.pkey,
node = node,
id = currency.id,
})
local pkey = currency.pkey
if currency.pkeyFormat == "kristwallet" then
pkey = currency.krypton:toKristWalletFormat(currency.pkey)
end
currency.host = currency.krypton:makev2address(pkey)
currency.krypton.privateKey = pkey
table.insert(self.currencies, currency)
local kryptonWs = currency.krypton:connect()
kryptonWs:subscribe("ownTransactions")
kryptonWs:getSelf()
if currency.name then
local name = currency.name
if name:find("%.") then
name = name:sub(1, name:find("%.") - 1)
end
local nameInfo
if currency.id ~= "carrotpay" then
nameInfo = currency.krypton:getName(name)
else
nameInfo = self:resolveCarrotPayName(name)
end
if not nameInfo then
error("Name " .. currency.name .. " does not exist!")
end
if nameInfo.name.owner:lower() ~= currency.host:lower() then
error("Name " .. currency.name .. " is not owned by " .. currency.host .. "!")
end
end
table.insert(self.kryptonListeners, function() kryptonWs:listen() end)
self.kryptonReady = true
end
end
-- Anytime the shop state is resumed, animation should be finished instantly. (call animation finish hooks)
---@param self ShopState
function ShopState:runShop()
-- Shop is starting
-- Wait for config ready
while not self.config.ready do sleep(0.5) end
self.running = true
self.currencies = {}
self.kryptonListeners = {}
self:setupKrypton()
ScanInventory.clearNbtCache()
local transactions = {}
parallel.waitForAny(function()
while true do
local event, transactionEvent = os.pullEvent("transaction")
if event == "transaction" then
local transactionCurrency = nil
for _, currency in ipairs(self.currencies) do
if currency.krypton.id == transactionEvent.source then
transactionCurrency = currency
break
end
end
if transactionCurrency then
local transaction = transactionEvent.transaction
local sentName = transaction.sent_name
local sentMetaname = transaction.sent_metaname
local nameSuffix = transactionCurrency.krypton.currency.name_suffix
if sentName and transactionCurrency.name and transactionCurrency.name:find(".") then
sentName = sentName .. "." .. nameSuffix
end
local meta = parseMeta(transaction.metadata)
if transactionCurrency.id == "carrotpay" then
-- Manually extract from meta
for i = 1, #meta do
local metaEntry = meta[i]
sentMetaname, sentName, carrotPaySuffix = metaEntry:match("(.+)@(.+)%.(%w+)")
if sentMetaname and sentName and carrotPaySuffix == "crt" then
sentName = sentName .. "." .. carrotPaySuffix
end
end
end
if transaction.from ~= transactionCurrency.host and (not transactionCurrency.name and not sentName) or (transactionCurrency.name and sentName and sentName:lower() == transactionCurrency.name:lower()) then
if transaction.to == transactionCurrency.host and not transactionCurrency.name and not sentMetaname then
sentMetaname = meta[1]
end
if sentMetaname then
local purchaseData = {
transaction = transaction,
meta = meta,
sentMetaname = sentMetaname,
transactionCurrency = transactionCurrency
}
transactions[#transactions + 1] = purchaseData
os.queueEvent("radon_purchase", purchaseData) -- for hooks that might be able to catch it (parallel).
elseif self.config.settings.refundMissingMetaname then
if self.config.settings.refundInvalidMetaname then
refund(transactionCurrency, transaction.from, meta, transaction.value, self.config.lang.refundNoProduct, true)
end
if self.eventHooks and self.eventHooks.failedPurchase then
eventHook.execute(self.eventHooks.failedPurchase, transaction, transactionCurrency, nil, self.config.lang.refundNoProduct)
end
end
end
end
end
end
end, function()
while self.running do
-- Run event hook for the parallel constant running task
-- This can do things like listen to events or host applications
if self.eventHooks and self.eventHooks.parallel then
eventHook.execute(self.eventHooks.parallel)
end
sleep(blinkFrequency)
end
end, function()
while self.running do
os.pullEvent("radon_purchase")
while #transactions > 0 do
local purchaseData = table.remove(transactions, 1)
local success, err = pcall(ShopState.handlePurchase, self, purchaseData.transaction, purchaseData.meta, purchaseData.sentMetaname, purchaseData.transactionCurrency)
if success then
-- Success :D
else
refund(purchaseData.transactionCurrency, purchaseData.transaction.from, purchaseData.meta, purchaseData.transaction.value, self.config.lang.refundError, true)
if self.eventHooks and self.eventHooks.failedPurchase then
eventHook.execute(self.eventHooks.failedPurchase, purchaseData.transaction, purchaseData.transactionCurrency, nil, self.config.lang.refundError)
end
error(err)
end
end
end
end, function()
local originalProducts = {}
for i = 1, #self.products do
originalProducts[i] = score.copyDeep(self.products[i])
end
while self.running do
local onInventoryRefresh = nil
if self.eventHooks and self.eventHooks.onInventoryRefresh then
onInventoryRefresh = self.eventHooks.onInventoryRefresh
end
local quantitiesChanged = ScanInventory.updateProductInventory(self.products, onInventoryRefresh)
if self.config.settings.hideUnavailableProducts then
self.productsChanged = true
end
if quantitiesChanged then
os.queueEvent("radon_shopsync_update")
end
sleep(self.config.settings.pollFrequency)
local productsChanged = hasProductsChanged(originalProducts, self.products)
if productsChanged then
os.queueEvent("radon_shopsync_update")
end
originalProducts = {}
for i = 1, #self.products do
originalProducts[i] = score.copyDeep(self.products[i])
end
end
end, function()
while self.running do
if self.config.settings.categoryCycleFrequency > 0 and os.epoch("utc") > self.lastTouched + (self.config.settings.activityTimeout * 1000) then
self.selectedCategory = self.selectedCategory + 1
if self.selectedCategory > self.numCategories then
self.selectedCategory = 1
end
self.productsChanged = true
end
sleep(math.min(1, self.config.settings.categoryCycleFrequency))
end
end, function()
local blinkState = false
while self.running do
blinkState = not blinkState
if self.config.peripherals.blinker then
redstone.setOutput(self.config.peripherals.blinker, blinkState)
end
if self.eventHooks and self.eventHooks.blink then
eventHook.execute(self.eventHooks.blink, blinkState)
end
sleep(blinkFrequency)
end
end, function()
local x, y, z = self.config.shopSync.location.x, self.config.shopSync.location.y, self.config.shopSync.location.z
local gpsX, gpsY, gpsZ
local lastShopSync = os.epoch("utc")
sleep(math.random() * 15 + 15)
os.queueEvent("radon_shopsync_update")
while self.running do
os.pullEvent("radon_shopsync_update")
if self.config.shopSync and self.config.shopSync.enabled and self.peripherals.shopSyncModem then
if ((not x or not y or not z) or (not self.config.shopSync.location.disableGPSPosVerification)) and (not gpsX or not gpsY or not gpsZ) then
gpsX, gpsY, gpsZ = gps.locate(5)
end
if (not x or not y or not z) and (gpsX and gpsY and gpsZ) then
x, y, z = gpsX, gpsY, gpsZ
end
if not self.config.shopSync.location.disableGPSPosVerification and (gpsX and gpsY and gpsZ and x and y and z and (gpsX ~= x or gpsY ~= y or gpsZ ~= z)) then
print(string.format("Shopsync: Warning: GPS position doesn't match configured position, using configured position anyways (conf: %d,%d,%d; GPS: %d,%d,%d)", x, y, z, gpsX, gpsY, gpsZ))
end
local now = os.epoch("utc")
if (now - lastShopSync) < (shopSyncFrequency * 1000) then
local timeToSleep = shopSyncFrequency - ((now - lastShopSync) / 1000)
sleep(timeToSleep)
end
lastShopSync = os.epoch("utc")
local items = {}
for i = 1, #self.products do
local product = self.products[i]
local prices = {}
local nbt = nil
local predicates = nil
if not product.bundle and not product.hidden and product.modid then
if product.predicates then
predicates = product.predicates
if predicates.nbt then
nbt = predicates.nbt
-- TODO: Multiple nbt hashes for all matched predicates?
end
end
for j = 1, #self.config.currencies do
local currency = self.config.currencies[j]
local currencyName = "KST"
if currency.krypton and currency.krypton.currency and currency.krypton.currency.currency_symbol then
currencyName = currency.krypton.currency.currency_symbol
end
local address = currency.host
local requiredMeta = nil
if currency.name then
address = product.address .. "@" .. currency.name
else
requiredMeta = product.address
end
local price = product.price / currency.value
if product.priceOverrides then
for _, override in pairs(product.priceOverrides) do
if override.currency == currency.id then
price = override.price
end
end
end
if price >= 0 then
table.insert(prices, {
value = price,
currency = currencyName,
address = address,
requiredMeta = requiredMeta
})
end
end
table.insert(items, {
prices = prices,
item = {
name = product.modid,
displayName = product.name,
nbt = nbt,
--predicates = predicates
},
dynamicPrice = false,
stock = product.quantity,
madeOnDemand = false,
})
end
end
self.peripherals.shopSyncModem.transmit(shopSyncChannel, os.getComputerID() % 65536, {
type = "ShopSync",
info = {
name = self.config.shopSync.name,
description = self.config.shopSync.description,
owner = self.config.shopSync.owner,
computerID = os.getComputerID(),
software = {
name = "Radon",
version = self.version
},
location = {
coordinates = {x, y, z},
description = self.config.shopSync.location.description,
dimension = self.config.shopSync.location.dimension
},
},
items = items
})
end
end
end, function()
while self.running do
if self.changedCurrencies and self.oldConfig then
self.changedCurrencies = false
self.kryptonReady = false
for i = 1, #self.oldConfig.currencies do
local currency = self.oldConfig.currencies[i]
if (currency.krypton and currency.krypton.ws) then
currency.krypton.ws:disconnect()
currency.krypton = nil
end
end
for i = 1, #self.currencies do
local currency = self.currencies[i]
if (currency.krypton and currency.krypton.ws) then
currency.krypton.ws:disconnect()
currency.krypton = nil
end
end
self:setupKrypton()
self.kryptonReady = true
self.oldConfig = nil
end
sleep(0.5)
end
end, function()
while self.running do
if self.kryptonReady then
parallel.waitForAny(function()
while self.kryptonReady do
sleep(0.5)
end
end, unpack(self.kryptonListeners))
end
sleep(0.5)
end
end)
end
return {
ShopState = ShopState
}
end
preload["core.ShopRunner"] = function(...)
local ShopState = require("core.ShopState")
local Animations = require("modules.hooks.animation")
local function areAnimationsFinished(uid)
local finished = Animations.animationFinished[uid]
if finished then
Animations.animationFinished[uid] = nil
return true
end
return false
end
local function launchShop(shopState, mainFunction)
parallel.waitForAny(function() shopState:runShop() end, mainFunction)
-- local shopCoroutine = coroutine.create(function() ShopState.runShop(shopState) end)
-- local mainCoroutine = coroutine.create(mainFunction)
-- local stateFilter ---@type "animationFinished" | "waitForPlayerInput"
-- local uidFilter
-- local eventFilter
-- local eventBacklog = {}
-- while true do
-- local e = (eventFilter == nil and #eventBacklog > 0) and table.remove(eventBacklog, 1) or { os.pullEvent() }
-- if eventFilter and e[1] ~= eventFilter then
-- eventBacklog[#eventBacklog+1] = e
-- else
-- local status, result = coroutine.resume(mainCoroutine, unpack(e))
-- eventFilter = result
-- if not status then
-- error(result)
-- end
-- end
-- if coroutine.status(mainCoroutine) == "dead" then
-- break
-- end
-- local canResume = coroutine.status(shopCoroutine) ~= "dead" -- true
-- if stateFilter == "animationFinished" then
-- canResume = areAnimationsFinished(uidFilter)
-- elseif stateFilter == "waitForPlayerInput" then
-- --canResume = isPlayerInputReady()
-- elseif stateFilter == "timer" then
-- canResume = e[1] == "timer" and e[2] == uidFilter
-- end
-- if canResume then
-- -- print("resuming...")
-- status, stateFilter, uidFilter = coroutine.resume(shopCoroutine)
-- if stateFilter then
-- print("new filter:", stateFilter)
-- end
-- if not status then
-- error(stateFilter)
-- end
-- if coroutine.status(shopCoroutine) == "dead" then
-- -- TODO: Reset game state
-- -- gameCoroutine = coroutine.create(GameState.runGame)
-- -- error("oops")
-- end
-- end
-- end
end
return {
launchShop = launchShop,
}
end
preload["core.Pricing"] = function(...)
local function getProductPrice(product, currency)
local price = product.price / currency.value
if product.priceOverrides then
for i = 1, #product.priceOverrides do
local override = product.priceOverrides[i]
if override.currency == currency.id then
price = override.price
break
end
end
end
return price
end
return {
getProductPrice = getProductPrice
}
end
preload["core.ConfigValidator"] = function(...)
local r2l = require("modules.regex")
local schemas = require("core.schemas")
local regexCache = {}
local function typeCheck(entryType, typeName, value, path)
if value then
if entryType == "table" and type(value) ~= "table" then
return { path = subpath, error = "Must be a table" }
end
if entryType == "string" and type(value) ~= "string" then
return { path = subpath, error = "Must be a string" }
end
if entryType == "number" and type(value) ~= "number" then
return { path = subpath, error = "Must be a number" }
end
if entryType == "function" and type(value) ~= "function" then
return { path = subpath, error = "Must be a function" }
end
if entryType == "file" then
if type(value) ~= "string" then
return { path = subpath, error = "Must be a file path" }
end
if not fs.exists(value) or fs.isDir(value) then
return { path = subpath, error = "File must exist" }
end
end
if entryType == "color" then
if type(value) ~= "number" then
return { path = subpath, error = "Must be a color" }
end
m,n = math.frexp(value)
if m ~= 0.5 or n < 1 or n > 16 then
return { path = subpath, error = "Must be a color" }
end
end
if entryType == "modem" then
if type(value) ~= "string" then
return { path = subpath, error = "Must be a modem name" }
end
if peripheral.getType(value) ~= "modem" then
return { path = subpath, error = "Must refer to a modem" }
end
end
if entryType == "speaker" then
if type(value) ~= "string" then
return { path = subpath, error = "Must be a speaker name" }
end
if peripheral.getType(value) ~= "speaker" then
return { path = subpath, error = "Must refer to a speaker" }
end
end
if entryType == "chest" then
if type(value) ~= "string" then
return { path = subpath, error = "Must be a chest name" }
end
-- If relative paths are fixed, add not turtle and
if value == "left" or value == "right" or value == "front" or value == "back" or value == "top" or value == "bottom" then
return { path = subpath, error = "Must be a network name" }
end
if not turtle and value == "self" then
return { path = subpath, error = "Can only be self for turtles" }
end
if value ~= "self" then
local chestMethods = peripheral.getMethods(value)
if not chestMethods then
return { path = subpath, error = "Must refer to a valid peripheral" }
end
local hasDropMethod = false
for i = 1, #chestMethods do
if chestMethods[i] == "drop" then
hasDropMethod = true
break
end
end
if not hasDropMethod then
return { path = subpath, error = "Must refer to an inventory" }
end
end
end
if entryType == "sound" then
if type(value) ~= "table" then
return { path = subpath, error = "Must be a sound" }
end
if not value.name or type(value.name) ~= "string" then
return { path = subpath, error = "Sound must have a name" }
end
if not value.volume or type(value.volume) ~= "number" then
return { path = subpath, error = "Sound must have a volume" }
end
if not value.pitch or type(value.pitch) ~= "number" then
return { path = subpath, error = "Sound must have a pitch" }
end
end
if entryType == "boolean" and type(value) ~= "boolean" then
return { path = subpath, error = "Must be a boolean" }
end
if entryType:sub(1, 5) == "enum<" and entryType:sub(-1) == ">" then
local enum = entryType:sub(6, -2)
local found = false
for enumValue in enum:gmatch("[^|]+") do
enumValue = enumValue:sub(enumValue:find("'(.*)'")):sub(2, -2)
if value == enumValue then
found = true
break
end
end
if not found then
if typeName then
return { path = subpath, error = "Must be entryType " .. typeName .. " matching " .. enum }
else
return { path = subpath, error = "Must match " .. enum }
end
end
end
if entryType:sub(1, 6) == "regex<" and entryType:sub(-1) == ">" then
local regexString = entryType:sub(7, -2)
if not regexCache[regexString] then
regexCache[regexString] = r2l.new(regexString)
end
local regex = regexCache[regexString]
if not regex(value) then
if typeName then
return { path = subpath, error = "Must be entryType " .. typeName .. " matching " .. regexString }
else
return { path = subpath, error = "Must match " .. regexString }
end
end
end
end
return nil
end
local function validate(config, schema, path)
if not path then
path = ""
end
if schema.__type then
if schema.__type:sub(1, 5) == "array" then
if schema.__type:sub(6, 6) == "?" and config == nil then
return
end
if type(config) ~= "table" then
return { path = path, error = "Must be an array" }
end
if schema.__min and #config < schema.__min then
return { path = path, error = "Must have at least " .. schema.__min .. " entries" }
end
if schema.__max and #config > schema.__max then
return { path = path, error = "Must have at most " .. schema.__max .. " entries" }
end
if schema.__entry then
local validationErrors = {}
for i = 1, #config do
if type(config[i]) ~= "table" or type(config[i]) ~= "table" then
local err = typeCheck(schema.__entry, schema.__entry, config[i], path .. "[" .. i .. "]")
if err then
table.insert(validationErrors, err)
end
else
local errs = validate(config[i], schema.__entry, path .. "[" .. i .. "]")
if errs and type(errs) == "table" and errs[1] then
for _, err in ipairs(errs) do
table.insert(validationErrors, err)
end
else
table.insert(validationErrors, errs)
end
end
end
if #validationErrors > 0 then
return validationErrors
end
end
end
else
if not config then
config = {}
end
local validationErrors = {}
for k,v in pairs(schema) do
subpath = path .. "." .. k
if type(v) == "table" then
local errs = validate(config[k], v, subpath)
if errs and type(errs) == "table" and errs[1] then
for _, err in ipairs(errs) do
table.insert(validationErrors, err)
end
else
table.insert(validationErrors, errs)
end
else
-- If regex or enum, get type name
-- E.g. regex<\w{10}>: address -> address
local typeDef, typeName
_, _, typeDef, typeName = v:find("^(%w+<.+>%??): (.+)$")
if typeDef then
v = typeDef
end
if v:sub(-1) ~= "?" and config[k] == nil then
table.insert(validationErrors, {
path = subpath,
error = "Missing required config value"
})
end
if v:sub(-1) == "?" then
v = v:sub(1, -2)
end
local err = typeCheck(v, typeName, config[k], subpath)
if err then
table.insert(validationErrors, err)
end
end
end
if #validationErrors > 0 then
return validationErrors
end
end
end
function validationArrayToMap(validationErrors)
local map = {}
if not validationErrors then
return map
end
for _, err in ipairs(validationErrors) do
if err.path then
map[err.path:gsub("%[(%d+)%]", "%.%1")] = err.error
end
end
return map
end
local function validateConfig(config)
return validate(config, schemas.configSchema, "config")
end
local function validateProducts(products)
return validate(products, schemas.productsSchema, "products")
end
local function validateHooks(hooks)
return validate(hooks, schemas.hooksSchema, "hooks")
end
return {
typeCheck = typeCheck,
validate = validate,
validationArrayToMap = validationArrayToMap,
validateConfig = validateConfig,
validateProducts = validateProducts,
validateHooks = validateHooks,
}
end
preload["configDefaults"] = function(...)
return {
branding = {
title = nil
},
settings = {
hideUnavailableProducts = false,
hideNegativePrices = true,
pollFrequency = 30,
categoryCycleFrequency = -1,
activityTimeout = 60,
dropDirection = "forward",
smallTextKristPayCompatability = true,
playSounds = true,
showFooter = true,
refundInvalidMetaname = true,
refundMissingMetaname = true,
refundInsufficentFunds = true
},
lang = {
footer = "/pay <item>@%name% <amt>",
footerNoName = "/pay %addr% <amt> <item>",
refundRemaining = "Here is the funds remaining after your purchase!",
refundOutOfStock = "Sorry, that item is out of stock!",
refundAtLeastOne = "You must purchase at least one of this product!",
refundInvalidProduct = "You must supply a valid product to purchase!",
refundNoProduct = "You must supply a product to purchase!",
refundError = "An error occurred while processing your purchase!",
refundDenied = "This purchase has been denied"
},
theme = {
formatting = {
headerAlign = "center",
subtitleAlign = "center",
footerAlign = "center",
footerSize = "auto",
productNameAlign = "center",
layout = "auto", -- "auto" automatically picks from "small", "medium", or "large"
-- based on the size of the screen
-- "custom" allows you to specify a custom layout file
--layoutFile = "CardLayout.lua"
},
colors = {
bgColor = colors.lightGray,
headerBgColor = colors.red,
headerColor = colors.white,
subtitleBgColor = colors.red,
subtitleColor = colors.white,
footerBgColor = colors.red,
footerColor = colors.white,
productBgColors = {
colors.blue,
},
outOfStockQtyColor = colors.red,
lowQtyColor = colors.orange,
warningQtyColor = colors.yellow,
normalQtyColor = colors.white,
productNameColor = colors.white,
outOfStockNameColor = colors.lightGray,
priceColor = colors.lime,
addressColor = colors.white,
currencyTextColor = colors.white,
currencyBgColors = {
colors.green,
colors.pink,
colors.lightBlue,
colors.yellow,
},
catagoryTextColor = colors.white,
categoryBgColors = {
colors.pink,
colors.orange,
colors.lime,
colors.lightBlue,
},
activeCategoryColor = colors.black,
},
palette = {
[colors.black] = 0x181818,
[colors.blue] = 0x182B52,
[colors.purple] = 0x7E2553,
[colors.green] = 0x008751,
[colors.brown] = 0xAB5136,
[colors.gray] = 0x565656,
[colors.lightGray] = 0x9D9D9D,
[colors.red] = 0xFF004C,
[colors.orange] = 0xFFA300,
[colors.yellow] = 0xFFEC23,
[colors.lime] = 0x00A23C,
[colors.cyan] = 0x29ADFF,
[colors.magenta] = 0x82769C,
[colors.pink] = 0xFF77A9,
[colors.lightBlue] = 0x3D7EDB,
[colors.white] = 0xECECEC
},
},
terminalTheme = {
colors = {
titleTextColor = colors.white,
titleBgColor = colors.blue,
bgColor = colors.black,
catagoryTextColor = colors.black,
catagoryBgColor = colors.white,
activeCatagoryBgColor = colors.lightGray,
logTextColor = colors.white,
configEditor = {
bgColor = colors.lime,
textColor = colors.black,
buttonColor = colors.green,
buttonTextColor = colors.white,
inactiveButtonColor = colors.gray,
inactiveButtonTextColor = colors.white,
scrollbarBgColor = colors.white,
scrollbarColor = colors.lightGray,
inputBgColor = colors.white,
inputTextColor = colors.black,
errorBgColor = colors.red,
errorTextColor = colors.white,
toggleColor = colors.lightGray,
toggleBgColor = colors.gray,
toggleOnColor = colors.lime,
toggleOffColor = colors.red,
unsavedChangesColor = colors.blue,
unsavedChangesTextColor = colors.white,
modalBgColor = colors.white,
modalTextColor = colors.black,
modalBorderColor = colors.lightGray,
},
productsEditor = {
bgColor = colors.lightBlue,
textColor = colors.black,
buttonColor = colors.blue,
buttonTextColor = colors.white,
inactiveButtonColor = colors.gray,
inactiveButtonTextColor = colors.white,
scrollbarBgColor = colors.white,
scrollbarColor = colors.lightGray,
inputBgColor = colors.white,
inputTextColor = colors.black,
errorBgColor = colors.red,
errorTextColor = colors.white,
toggleColor = colors.lightGray,
toggleBgColor = colors.gray,
toggleOnColor = colors.lime,
toggleOffColor = colors.red,
unsavedChangesColor = colors.green,
unsavedChangesTextColor = colors.white,
modalBgColor = colors.white,
modalTextColor = colors.black,
modalBorderColor = colors.lightGray,
}
},
palette = {
[colors.black] = 0x111111,
[colors.blue] = 0x3366cc,
[colors.purple] = 0xb266e5,
[colors.green] = 0x57a64e,
[colors.brown] = 0x7f664c,
[colors.gray] = 0x4c4c4c,
[colors.lightGray] = 0x999999,
[colors.red] = 0xcc4c4c,
[colors.orange] = 0xf2b233,
[colors.yellow] = 0xdede6c,
[colors.lime] = 0x7fcc19,
[colors.cyan] = 0x4c99b2,
[colors.magenta] = 0xe57fd8,
[colors.pink] = 0xf2b2cc,
[colors.lightBlue] = 0x99b2f2,
[colors.white] = 0xf0f0f0
}
},
sounds = {
button = {
name = "minecraft:block.note_block.hat",
volume = 0.5,
pitch = 1.1
},
purchase = {
name = "minecraft:block.note_block.pling",
volume = 0.5,
pitch = 2
},
},
shopSync = {
enabled = false,
name = "Radon Shop",
description = "A radon Shop",
owner = nil,
location = {
x = nil, -- Coordinates are determined using GPS if none are set, but I'd recommend setting them
y = nil, -- Make sure to use the exact position of the turtle/computer
z = nil,
disableGPSPosVerification = false, -- Complains in logs if the set position and GPS position doesn't match, this prevents people from setting the incorrect coordinates if GPS is available
description = nil,
dimension = "overworld"
}
},
peripherals = {
monitor = nil, -- Monitor to display on, if not specified, will use the first monitor found
modem = nil, -- Modem for inventories, if not specified, will use the first wired modem found
speaker = nil, -- Speaker to play sounds on, if not specified, will use the first speaker found
shopSyncModem = nil, -- Modem for ShopSync, if not specified, will use the first wireless modem found
blinker = nil, -- Side that a redstone lamp or other redstone device is on
-- Will be toggled on and off every 3 seconds to indicate that the shop is online
exchangeChest = nil,
outputChest = "self", -- Chest peripheral or self
-- NOTE: Chest dropping is NYI in plethora 1.19, so do not use unless
-- the output chest can be accessed
},
hooks = {
start = nil, -- function(version, config, products)
prePurchase = nil, -- function(product, amount, refundAmount, transaction, transactionCurrency) returns continueTransaction, error, errorMessage
purchase = nil, -- function(product, amount, refundAmount, transaction, transactionCurrency)
failedPurchase = nil, -- function(transaction, transactionCurrency, product, errorMessage)
programError = nil, -- function(err)
blink = nil, -- function(blinkState) called every 3 seconds while shop is running
},
exchange = {
-- Not yet implemented
enabled = true,
node = "https://localhost:8000/"
}
}
end
preload["components.Toggle"] = function(...)
local Solyd = require("modules.solyd")
local hooks = require("modules.hooks")
local useBoundingBox = hooks.useBoundingBox
local useInput = hooks.useInput
local Rect = require("components.Rect")
return Solyd.wrapComponent("Toggle", function(props)
--print("Test")
-- local canvas = Solyd.useContext("canvas")
-- local canvas = useCanvas()
if not props.inputState.value then
props.inputState.value = false
end
local inputState, setInputState = Solyd.useState(props.inputState)
local offColor = props.offColor
local onColor = props.bg
if inputState.value then
offColor = props.bg
onColor = props.onColor
end
local stateWidth = math.floor(props.width / 3)
local stateMiddle = props.width - (stateWidth * 2)
return {
Rect {
key = "toggle-off-" .. props.key,
display = props.display,
x = props.x,
y = props.y,
width = stateWidth,
height = props.height,
color = offColor
},
Rect {
key = "toggle-middle-" .. props.key,
display = props.display,
x = props.x + stateWidth,
y = props.y,
width = stateMiddle,
height = props.height,
color = props.color
},
Rect {
key = "toggle-on-" .. props.key,
display = props.display,
x = props.x + stateWidth + stateMiddle,
y = props.y,
width = stateWidth,
height = props.height,
color = onColor
},
},
{
-- canvas = canvas,
aabb = useBoundingBox(props.x, props.y, props.width, props.height, function() inputState.value = not inputState.value setInputState(inputState) if props.onChange then props.onChange(inputState.value) end end),
}
end)
end
preload["components.TextInput"] = function(...)
local Solyd = require("modules.solyd")
local hooks = require("modules.hooks")
local useBoundingBox = hooks.useBoundingBox
local useInput = hooks.useInput
local BasicText = require("components.BasicText")
local function numToHex(num)
return "#" .. string.format("%06x", num)
end
return Solyd.wrapComponent("TextInput", function(props)
--print("Test")
-- local canvas = Solyd.useContext("canvas")
-- local canvas = useCanvas()
if props.inputState.value and type(props.inputState.value) == "number" then
if props.type == "number" then
props.inputState.value = tostring(props.inputState.value)
elseif props.type == "colorpicker" then
props.inputState.value = numToHex(props.inputState.value)
end
end
if not props.inputState.value then
props.inputState.value = ""
end
local inputState, setInputState = Solyd.useState(props.inputState)
if not inputState.prevValue then
inputState.prevValue = inputState.value
--setInputState(inputState)
end
if not inputState.active then
inputState.active = false
--setInputState(inputState)
end
inputState.cursorY = props.y
if not inputState.cursorPos then
inputState.cursorPos = #inputState.value + 1
inputState.viewPort = 1
inputState.cursorX = props.x + inputState.cursorPos - 1
--setInputState(inputState)
end
function addChar(char)
if props.type == "number" then
if char == "." then
if inputState.value:find("%.") then
return
end
elseif char == "-" then
if inputState.cursorPos ~= 1 or inputState.value:find("%-") then
return
end
elseif char:match("%D") then
return
end
elseif props.type == "colorpicker" then
if char == "x" then
if inputState.cursorPos == 1 and inputState.value:find("x") then
return
elseif inputState.cursorPos == 2 and (inputState.value:sub(1, 1) ~= "0" or inputState.value:find("x")) then
return
elseif inputState.cursorPos >= 3 then
return
end
elseif char == "#" then
if inputState.cursorPos == 1 and inputState.value:find("#") then
return
elseif inputState.cursorPos >= 2 then
return
end
elseif char:match("%X") then
return
else
if inputState.cursorPos == 1 and (inputState.value:find("x") or inputState.value:find("#")) then
return
elseif inputState.cursorPos == 2 and (inputState.value:sub(2, 2) == "x") then
return
end
end
end
inputState.value = inputState.value:sub(1, inputState.cursorPos-1) .. char .. inputState.value:sub(inputState.cursorPos)
inputState.cursorPos = inputState.cursorPos + 1
inputState.cursorX = props.x + inputState.cursorPos - inputState.viewPort
if inputState.cursorPos > inputState.viewPort + props.width - 1 then
inputState.viewPort = inputState.viewPort + 1
end
setInputState(inputState)
end
return BasicText {
display = props.display,
align = props.align,
text = inputState.value:sub(inputState.viewPort, inputState.viewPort + props.width - 1),
x = props.x,
y = props.y,
bg = props.bg,
color = props.color,
width = props.width,
},
---@diagnostic disable-next-line: redundant-return-value
{
-- canvas = canvas,
aabb = useBoundingBox((props.x*2)-1, (props.y*3)-2, (props.width)*2, (props.height)*3,
function() -- onClick
inputState.active = true
inputState.viewPort = math.max(1, inputState.cursorPos - props.width + 1)
inputState.cursorX = props.x + inputState.cursorPos - inputState.viewPort
setInputState(inputState)
end,
function(dir) -- onScroll
if props.type == "number" and inputState.value and inputState.value ~= "" then
inputState.value = tostring(tonumber(inputState.value) - dir)
setInputState(inputState)
if props.onChange then
if props.type == "number" and inputState.value ~= nil then
props.onChange(tonumber(inputState.value))
else
props.onChange(inputState.value)
end
end
return true
end
end),
input = useInput(props.x, props.y, props.width, props.height, inputState, addChar,
function(key, held)
if key == keys.backspace then
if inputState.cursorPos > 1 then
inputState.value = inputState.value:sub(1, inputState.cursorPos-2) .. inputState.value:sub(inputState.cursorPos)
inputState.cursorPos = inputState.cursorPos - 1
inputState.cursorX = props.x + inputState.cursorPos - inputState.viewPort
if inputState.cursorPos < inputState.viewPort then
inputState.viewPort = inputState.viewPort - 1
end
setInputState(inputState)
end
elseif key == keys.delete then
if inputState.cursorPos < #inputState.value + 1 then
inputState.value = inputState.value:sub(1, inputState.cursorPos-1) .. inputState.value:sub(inputState.cursorPos+1)
setInputState(inputState)
end
elseif key == keys.enter then
inputState.active = false
inputState.viewPort = 1
if inputState.value ~= inputState.prevValue then
if props.onChange then
if props.type == "number" and inputState.value ~= nil then
props.onChange(tonumber(inputState.value))
elseif props.type == "colorpicker" and inputState.value ~= nil then
-- Convert hex to number
local hex = inputState.value
hex = hex:gsub("#", "")
hex = hex:gsub("x", "")
props.onChange(tonumber(hex, 16))
else
props.onChange(inputState.value)
end
end
inputState.prevValue = inputState.value
end
setInputState(inputState)
elseif key == keys.left then
if inputState.cursorPos > 1 then
inputState.cursorPos = inputState.cursorPos - 1
if inputState.cursorPos < inputState.viewPort then
inputState.viewPort = inputState.viewPort - 1
end
inputState.cursorX = props.x + inputState.cursorPos - inputState.viewPort
setInputState(inputState)
end
elseif key == keys.right then
if inputState.cursorPos < #inputState.value + 1 then
inputState.cursorPos = inputState.cursorPos + 1
if inputState.cursorPos > inputState.viewPort + props.width - 1 then
inputState.viewPort = inputState.viewPort + 1
end
inputState.cursorX = props.x + inputState.cursorPos - inputState.viewPort
setInputState(inputState)
end
elseif key == keys.home then
inputState.cursorPos = 1
inputState.viewPort = 1
inputState.cursorX = props.x + inputState.cursorPos - inputState.viewPort
setInputState(inputState)
elseif key == keys["end"] then
inputState.cursorPos = #inputState.value + 1
inputState.viewPort = math.max(1, inputState.cursorPos - props.width + 1)
inputState.cursorX = props.x + inputState.cursorPos - inputState.viewPort
setInputState(inputState)
end
end,
function()
-- On blur
if inputState.value ~= inputState.prevValue then
if props.onChange then
if props.type == "number" and inputState.value ~= nil then
props.onChange(tonumber(inputState.value))
else
props.onChange(inputState.value)
end
end
inputState.prevValue = inputState.value
end
setInputState(inputState)
end,
function(contents)
-- On paste
if props.type == "number" then
if contents:sub(1, 1) == "-" then
contents = "-" .. contents:gsub("[^%d]", "")
else
contents = contents:gsub("[^%d]", "")
end
elseif props.type == "colorpicker" then
if contents:sub(1, 1) ~= "#" or contents:sub(1,2):find("x") then
contents = "#" .. contents:gsub("[^%x]", "")
else
contents:gsub("[^%x]", "")
end
end
inputState.value = inputState.value:sub(1, inputState.cursorPos-1) .. contents .. inputState.value:sub(inputState.cursorPos)
inputState.cursorPos = inputState.cursorPos + #contents
if inputState.cursorPos > inputState.viewPort + props.width - 1 then
inputState.viewPort = inputState.cursorPos - props.width + 2
end
inputState.cursorX = props.x + inputState.cursorPos - inputState.viewPort
setInputState(inputState)
end
),
}
end)
end
preload["components.Sprite"] = function(...)
local Solyd = require("modules.solyd")
local hooks = require("modules.hooks")
local useCanvas = hooks.useCanvas
return Solyd.wrapComponent("Sprite", function(props)
local canvas = useCanvas(props.display, props.sprite.width, props.sprite.height)
Solyd.useEffect(function()
-- local s, x, y = props.sprite, props.x, props.y
canvas:drawCanvas(props.sprite, props.x, props.y, props.remapFrom, props.remapTo)
-- canvas:drawCanvasRotated(props.sprite, props.x, props.y, props.angle or 0)
return function()
canvas:markCanvas(props.sprite, props.x, props.y)
-- canvas:markCanvasRotated(props.sprite, props.x, props.y, props.angle or 0)
end
end, { props.sprite, props.x, props.y, props.remapFrom, props.remapTo })
end)
end
preload["components.SmolText"] = function(...)
local Solyd = require("modules.solyd")
local hooks = require("modules.hooks")
local useCanvas = hooks.useCanvas
local smolFont = require("fonts.smolfont")
return Solyd.wrapComponent("SmolText", function(props)
local fw = props.width or smolFont:getWidth(props.text)+2
local bgHeight = 3
local canvas = useCanvas(props.display, fw, smolFont.height+bgHeight)--Solyd.useContext("canvas")
Solyd.useEffect(function()
if props.bg then
for x = 1, fw do
for y = 1, smolFont.height+bgHeight do
canvas:setPixel(x, y, props.bg)
end
end
end
local cx = 0
if props.width then
if props.align == "center" then
cx = math.floor((props.width - smolFont:getWidth(props.text)) / 2)
elseif props.align == "right" then
cx = props.width - smolFont:getWidth(props.text) - 2
end
end
smolFont:write(canvas, props.text, 2 + cx, bgHeight+1, props.color or colors.white)
return function()
canvas:markRect(1, 1, fw, smolFont.height+bgHeight)
end
end, { canvas, props.display, props.align, props.text, props.color, props.bg, fw })
local x = props.right and props.x-canvas.width+1 or props.x
return nil, { canvas = { canvas, x, props.y } }
end)
end
preload["components.SmolButton"] = function(...)
local Solyd = require("modules.solyd")
local hooks = require("modules.hooks")
local useBoundingBox = hooks.useBoundingBox
local SmolText = require("components.SmolText")
local smolFont = require("fonts.smolfont")
return Solyd.wrapComponent("SmolButton", function(props)
-- local canvas = Solyd.useContext("canvas")
-- local canvas = useCanvas()
return SmolText {
display = props.display,
align = props.align,
text = props.text,
x = props.x,
y = props.y,
bg = props.bg,
color = props.color,
width = props.width,
}, {
-- canvas = canvas,
aabb = useBoundingBox(props.x, props.y, props.width or smolFont:getWidth(props.text), smolFont.height+3, props.onClick),
}
end)
end
preload["components.Select"] = function(...)
local Solyd = require("modules.solyd")
local hooks = require("modules.hooks")
local useBoundingBox = hooks.useBoundingBox
local useInput = hooks.useInput
local Rect = require("components.Rect")
local BasicText = require("components.BasicText")
local BasicButton = require("components.BasicButton")
local Scrollbar = require("components.Scrollbar")
return Solyd.wrapComponent("Select", function(props)
--print("Test")
-- local canvas = Solyd.useContext("canvas")
-- local canvas = useCanvas()
local modal = Solyd.useContext("modal")
local modalElements = modal[0]
local setModalElements = modal[1]
-- if not props.inputState.value then
-- props.inputState.value = props.options[1].value
-- end
if not props.inputState.active then
props.inputState.active = false
end
if not props.inputState.scroll then
props.inputState.scroll = 0
end
if not props.inputState.maxScroll then
props.inputState.maxScroll = math.max(#props.options - props.height, 0)
end
local inputState, setInputState = Solyd.useState(props.inputState)
local newMaxScroll = math.max(#props.options - props.height, 0)
if newMaxScroll ~= inputState.maxScroll then
inputState.maxScroll = newMaxScroll
setInputState(inputState)
end
local elements = {}
local elementHeight = 3
local xOffset = -1
if inputState.active and modalElements then
xOffset = 3
for i = inputState.scroll+1, inputState.scroll + math.min(#props.options - inputState.scroll, props.height) do
table.insert(modalElements, BasicButton {
key = "select-option-" .. props.key .. "-" .. i,
display = props.display,
text = props.options[i].text,
x = props.x + 2,
y = props.y + (i - 1 - inputState.scroll),
width = props.width - 3,
height = props.height,
bg = props.bg,
color = props.color,
onClick = function()
inputState.value = props.options[i].value
inputState.active = false
setInputState(inputState)
for j = 1, #modalElements do
table.remove(modalElements, 1)
end
setModalElements(modalElements)
if props.onChange then
props.onChange(inputState.value)
end
end,
onScroll = function(dir)
if dir <= -1 then
inputState.scroll = math.max(inputState.scroll + dir, 0)
setInputState(inputState)
elseif dir >= 1 then
inputState.scroll = math.min(inputState.scroll + dir, inputState.maxScroll)
setInputState(inputState)
end
return true
end
})
end
elementHeight = math.min(#props.options - inputState.scroll, props.height) * 3
if #props.options > props.height then
table.insert(modalElements, Scrollbar {
key = "select-scrollbar-" .. props.key,
display = props.display,
x = (props.x + props.width - 1)*2 - 1,
y = (props.y*3)-2,
width = 2,
height = props.height * 3,
areaHeight = props.height * 3,
scroll = inputState.scroll * 3,
maxScroll = inputState.maxScroll * 3,
color = props.scrollbarColor,
bg = props.bg,
})
end
setModalElements(modalElements)
else
local valueText = inputState.value
for i = 1, #props.options do
if props.options[i].value == inputState.value then
valueText = props.options[i].text
end
end
table.insert(elements, BasicText {
key = "select-value-" .. props.key,
display = props.display,
text = valueText or "",
x = props.x+2,
y = props.y,
width = props.width-2,
height = 1,
color = props.color,
bg = props.bg,
})
if setModalElements then
setModalElements({})
end
end
local arrow = "> "
if inputState.active then
arrow = "v "
end
table.insert(elements, BasicText {
key = "select-arrow-" .. arrow .. "-" .. props.key,
display = props.display,
text = arrow,
x = props.x,
y = props.y,
width = 2,
height = 1,
color = props.toggleColor,
bg = props.bg,
})
return elements,
{
-- canvas = canvas,
aabb = useBoundingBox((props.x*2)+xOffset, (props.y*3)-1, props.width*2, elementHeight, function()
inputState.active = true
setInputState(inputState)
end,
function(dir) -- onScroll
if inputState.active then
if dir <= -1 then
inputState.scroll = math.max(inputState.scroll + dir, 0)
setInputState(inputState)
elseif dir >= 1 then
inputState.scroll = math.min(inputState.scroll + dir, inputState.maxScroll)
setInputState(inputState)
end
return true
end
end),
input = useInput((props.x*2)+xOffset, (props.y*3)-1, props.width*2, elementHeight, inputState, function(char)
-- Select based on first letter
setInputState(inputState)
end,
function(key, held)
if key == keys.backspace then
inputState.value = nil
setInputState(inputState)
elseif key == keys.delete then
inputState.value = nil
setInputState(inputState)
elseif key == keys.enter then
inputState.active = false
if props.onChange then
props.onChange(inputState.value)
end
setInputState(inputState)
for i = 1, #modalElements do
table.remove(modalElements, 1)
end
setModalElements(modalElements)
elseif key == keys.up then
inputState.scroll = math.max(inputState.scroll - 1, 0)
setInputState(inputState)
elseif key == keys.down then
inputState.scroll = math.min(inputState.scroll + 1, inputState.maxScroll)
setInputState(inputState)
end
end,
function()
-- On blur
inputState.active = false
setInputState(inputState)
for i = 1, #modalElements do
table.remove(modalElements, 1)
end
setModalElements(modalElements)
end
),
}
end)
end
preload["components.Scrollbar"] = function(...)
local Solyd = require("modules.solyd")
local hooks = require("modules.hooks")
local Rect = require("components.Rect")
local useCanvas = hooks.useCanvas
return Solyd.wrapComponent("Scrollbar", function(props)
local canvas = useCanvas(props.display, props.width, props.height)--Solyd.useContext("canvas")
local elements = {}
local scrollBarSize = math.max(
0,
math.floor(props.height * (props.areaHeight / (props.maxScroll + props.areaHeight)))
)
local scrollProgress = props.scroll / props.maxScroll
local scrollSpace = props.height - scrollBarSize
local scrollBarPos = math.max(0,
math.min(
math.floor(props.height - scrollBarSize),
math.floor(scrollSpace * scrollProgress)
)
)
table.insert(elements, Rect {
key = "scrollbar-bg-" .. props.key,
display = props.display,
x = props.x,
y = props.y,
width = props.width,
height = props.height,
color = props.bg,
})
table.insert(elements, Rect {
key = "scrollbar-" .. props.key,
display = props.display,
x = props.x,
y = props.y + scrollBarPos,
width = props.width,
height = scrollBarSize,
color = props.color,
})
return elements, { canvas = { canvas, props.x, props.y } }
end)
end
preload["components.RenderCanvas"] = function(...)
local Solyd = require("modules.solyd")
local Util = require("util.misc")
---@param props { canvas: PixelCanvas, x: integer, y: integer, remap: table }
return Solyd.wrapComponent("RenderCanvas", function(props)
local remapped = Solyd.useMemo(function()
if props.remap then
return props.canvas:clone():mapColors(props.remap)
else
return props.canvas
end
end, {props.canvas, props.remap})
return {}, { canvas = { remapped, props.x, props.y } }
end)
end
preload["components.Rect"] = function(...)
local Solyd = require("modules.solyd")
local hooks = require("modules.hooks")
local useCanvas = hooks.useCanvas
return Solyd.wrapComponent("Rect", function(props)
local canvas = useCanvas(props.display, props.width, props.height)--Solyd.useContext("canvas")
Solyd.useEffect(function()
if props.color then
for x = 1, props.width do
for y = 1, props.height do
canvas:setPixel(x, y, props.color)
end
end
end
return function()
canvas:markRect(1, 1, props.width, props.height)
end
end, { canvas, props.display, props.x, props.y, props.width, props.height, props.color })
return nil, { canvas = { canvas, props.x, props.y } }
end)
end
preload["components.Modal"] = function(...)
local Solyd = require("modules.solyd")
local hooks = require("modules.hooks")
local useBoundingBox = hooks.useBoundingBox
local useInput = hooks.useInput
local Rect = require("components.Rect")
local BasicText = require("components.BasicText")
local BasicButton = require("components.BasicButton")
local Scrollbar = require("components.Scrollbar")
return Solyd.wrapComponent("Modal", function(props)
--print("Test")
-- local canvas = Solyd.useContext("canvas")
-- local canvas = useCanvas()
local modal = Solyd.useContext("modal")
if not modal[0] then
modal[0], modal[1] = Solyd.useState({})
end
local modalElements = modal[0]
local setModalElements = modal[1]
local elements = {}
for i = 1, #modalElements do
table.insert(elements, modalElements[i])
end
return elements, {}
end)
end
preload["components.Logs"] = function(...)
local Solyd = require("modules.solyd")
local hooks = require("modules.hooks")
local BasicText = require("components.BasicText")
local useTextCanvas = hooks.useTextCanvas
return Solyd.wrapComponent("Logs", function(props)
local canvas = useTextCanvas(props.display, props.width*2, props.height*3)
local texts = {}
local logMessageY = props.height
for i = 1, math.min(#props.logs, props.height) do
local logMessage = "[" .. textutils.formatTime(props.logs[i].time, true) .. "] " .. props.logs[i].text
local numLines = math.ceil(#logMessage / props.width)
if logMessageY - numLines + 1 < 0 then
break
end
for j = 1, numLines do
local line = logMessage:sub((j - 1) * props.width + 1, j * props.width)
table.insert(texts, BasicText {
key = "logs-"..tostring(i).."-"..tostring(j),
display = props.display,
align = "left",
text = line,
x = 1,
y = logMessageY - numLines + j + 1,
color = props.color,
bg = props.bg,
})
end
logMessageY = logMessageY - numLines
end
return texts, { canvas = { canvas, props.x*2-1, props.y*3-2 } }
end)
end
preload["components.Flex"] = function(...)
local _ = require("util.score")
local Solyd = require("modules.solyd")
---@param props { x: integer, y: integer, width: integer, children: SolydElement[] }
return Solyd.wrapComponent("Flex", function(props)
local remainingWidth = props.width
local children, flexElCount, flexCount = {}, 0, 0
for i, child in ipairs(props.children) do
if type(child) == "table" then
if child.props.width then
table.insert(children, { element = child, width = child.props.width })
remainingWidth = remainingWidth - child.props.width - (i > 1 and 1 or 0)
else
local flex = child.props.flex or 1
table.insert(children, { element = child, flex = flex })
flexCount = flexCount + flex
flexElCount = flexElCount + 1
end
end
end
local x = props.x
local flexWidth = math.ceil((remainingWidth - flexElCount) / flexCount)
remainingWidth = props.width
for i, child in ipairs(children) do
local width = math.min(remainingWidth, child.width or flexWidth*child.flex)
child.element.props.width = width
child.element.props.x = x
child.element.props.y = props.y
x = x + width + 1
remainingWidth = remainingWidth - width - (i > 1 and 1 or 0)
end
return _.map(children, function(el) return el.element end)
end)
end
preload["components.ConfigEditor"] = function(...)
local Solyd = require("modules.solyd")
local hooks = require("modules.hooks")
local Alert = require("components.Alert")
local Scrollbar = require("components.Scrollbar")
local BasicText = require("components.BasicText")
local BasicButton = require("components.BasicButton")
local TextInput = require("components.TextInput")
local Select = require("components.Select")
local Toggle = require("components.Toggle")
local Rect = require("components.Rect")
local configHelpers = require("util.configHelpers")
local ConfigValidator = require("core.ConfigValidator")
local useTextCanvas = hooks.useTextCanvas
local schemas = require("core.schemas")
local score = require("util.score")
return Solyd.wrapComponent("ConfigEditor", function(props)
local canvas = useTextCanvas(props.display, props.width*2, props.height*3)
local theme = props.theme
local modal = Solyd.useContext("modal")
local modalElements = modal[0]
local setModalElements = modal[1]
local configDiffs, setConfigDiffs = Solyd.useState({})
local arrayAdds, setArrayAdds = Solyd.useState({})
local arrayRemoves, setArrayRemoves = Solyd.useState({})
local saveModalOpen, setSaveModalOpen = Solyd.useState(false)
local updates, setUpdates = Solyd.useState(0)
local errors, setErrors = Solyd.useState(props.errors or {})
if not props.terminalState.configPath then
props.terminalState.configPath = ""
end
local subConfig = props.config
local subSchema = props.schema
local paths = {}
local unsavedChanges = false
for k,v in pairs(configDiffs) do
unsavedChanges = true
break
end
for k,v in pairs(arrayAdds) do
unsavedChanges = true
break
end
for k,v in pairs(arrayRemoves) do
unsavedChanges = true
break
end
if arrayAdds[props.terminalState.configPath] or arrayAdds["." .. props.terminalState.configPath] then
subConfig = {}
end
for path in props.terminalState.configPath:gmatch("([^%[?%]?%.?]+)") do
if path:match("%d+") then
path = tonumber(path) or path
end
if subConfig[path] then
subConfig = subConfig[path]
else
--subConfig[path] = {}
--subConfig = subConfig[path]
subConfig = {}
end
if subSchema[path] then
subSchema = subSchema[path]
if subSchema == "sound" or subSchema == "sound?" then
subSchema = schemas.soundSchema
end
elseif subSchema.__type and subSchema.__type:sub(1,5) == "array" and subSchema.__entry then
subSchema = subSchema.__entry
else
--subSchema[path] = {}
--subSchema = subSchema[path]
subSchema = {}
end
table.insert(paths, path)
end
local elements = {}
table.insert(elements, Rect {
key = "bg",
display = props.display,
x = (props.x*2)-1,
y = (props.y*3)-2,
width = props.width*2,
height = props.height*3,
color = theme.bgColor,
})
if errors and #errors > 0 then
table.insert(elements, BasicButton {
key = "save-error",
display = props.display,
x = props.x,
y = props.y,
text = "Save(!)",
onClick = function()
-- Open modal to confirm
setSaveModalOpen(true)
end,
bg = theme.errorBgColor,
color = theme.errorTextColor,
})
elseif unsavedChanges then
table.insert(elements, BasicButton {
key = "save-unsaved",
display = props.display,
x = props.x,
y = props.y,
text = " Save ",
onClick = function()
-- Open modal to confirm
setSaveModalOpen(true)
end,
bg = theme.unsavedChangesColor,
color = theme.unsavedChangesTextColor,
})
else
table.insert(elements, BasicButton {
key = "save-disabled",
display = props.display,
x = props.x,
y = props.y,
text = " Save ",
onClick = function()
-- Do nothing, nothing to save
end,
bg = theme.inactiveButtonColor,
color = theme.inactiveButtonTextColor,
})
end
if #paths > 0 then
table.insert(elements, BasicButton {
key = "back",
display = props.display,
x = props.x + 8,
y = props.y,
text = " Back ",
onClick = function()
if #paths > 0 then
props.terminalState.scroll = 0
table.remove(paths)
props.terminalState.configPath = table.concat(paths, ".")
end
end,
bg = theme.buttonColor,
color = theme.buttonTextColor,
})
table.insert(elements, BasicText {
key = "path",
display = props.display,
x = props.x + 15,
y = props.y,
text = props.terminalState.configPath,
bg = theme.buttonColor,
color = theme.buttonTextColor,
})
end
local elementY = 0
local numKeys = 0
if type(subSchema) == "table" then
if not subSchema.__type or true then
local fields = subSchema
local xOffset = 0
local isArray = false
local arrayLabel = nil
if subSchema.__type and subSchema.__type:sub(1,5) == "array" and subSchema.__entry then
fields = score.copyDeep(subConfig)
numKeys = numKeys + 1
xOffset = 1
isArray = true
if subSchema.__label then
arrayLabel = subSchema.__label
end
end
for k, _ in pairs(fields) do
numKeys = numKeys + 1
end
if subSchema.__type and subSchema.__type:sub(1,5) == "array" and subSchema.__entry then
while not fields[numKeys] and arrayAdds[props.terminalState.configPath .. "." .. tostring(numKeys)] do
fields[numKeys] = {}
numKeys = numKeys + 1
end
end
props.terminalState.maxScroll = math.max(0, (numKeys*3) - props.height)
local lastSelect = false
local editorFields = {}
for k, _ in pairs(fields) do
table.insert(editorFields, k)
end
-- Sort editorFields alphabetically
table.sort(editorFields, function(a, b)
-- Return whether the string a should come before b alphabetically
if type(a) == "number" and type(b) == "number" then
return a < b
elseif type(a) == "number" then
return true
elseif type(b) == "number" then
return false
else
return a < b
end
end)
for _, k in pairs(editorFields) do
v = fields[k]
lastSelect = false
local textY = props.y + 1 + elementY - props.terminalState.scroll
local fullPath = props.terminalState.configPath .. "." .. tostring(k)
local buttonColor = theme.buttonColor
local buttonTextColor = theme.buttonTextColor
if arrayRemoves[fullPath] then
buttonColor = theme.inactiveButtonColor
buttonTextColor = theme.inactiveButtonTextColor
if subSchema.__type and subSchema.__type:sub(1,5) == "array" and subSchema.__entry then
v = subSchema.__entry
k = tostring(k)
if textY >= props.y + 1 and textY <= props.y + props.height then
table.insert(elements, BasicButton {
key = "restore-" .. k,
display = props.display,
x = props.x,
y = textY,
text = "o",
color = buttonColor,
bg = buttonTextColor,
onClick = function()
arrayRemoves[fullPath] = nil
setArrayRemoves(arrayRemoves)
setUpdates(updates + 1)
local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves)
setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix))
end
})
end
if textY + 1 >= props.y + 1 and textY + 1 <= props.y + props.height then
table.insert(elements, BasicButton {
key = "restore-spacer-" .. k,
display = props.display,
x = props.x,
y = textY+1,
text = " ",
color = buttonColor,
bg = buttonTextColor,
onClick = function()
arrayRemoves[fullPath] = nil
setArrayRemoves(arrayRemoves)
setUpdates(updates + 1)
local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves)
setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix))
end
})
end
end
else
if subSchema.__type and subSchema.__type:sub(1,5) == "array" and subSchema.__entry then
v = subSchema.__entry
k = tostring(k)
if textY >= props.y + 1 and textY <= props.y + props.height then
table.insert(elements, BasicButton {
key = "delete-" .. k,
display = props.display,
x = props.x,
y = textY,
text = "x",
color = theme.errorTextColor,
bg = theme.errorBgColor,
onClick = function()
arrayRemoves[fullPath] = true
setArrayRemoves(arrayRemoves)
setUpdates(updates + 1)
local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves)
setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix))
end
})
end
if textY + 1 >= props.y + 1 and textY + 1 <= props.y + props.height then
table.insert(elements, BasicButton {
key = "delete-spacer-" .. k,
display = props.display,
x = props.x,
y = textY+1,
text = " ",
color = theme.errorTextColor,
bg = theme.errorBgColor,
onClick = function()
arrayRemoves[fullPath] = true
setArrayRemoves(arrayRemoves)
setUpdates(updates + 1)
local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves)
setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix))
end
})
end
end
end
if errors and #errors > 0 then
for i = 1, #errors do
-- error on config.branding.title will trigger for config.branding
local pathMatch = props.errorPrefix .. "." .. fullPath
if fullPath:sub(1,1) == "." then
pathMatch = props.errorPrefix .. fullPath
end
local errorPath = errors[i].path:gsub("%[(%d+)%]", "%.%1")
if errorPath == pathMatch or errorPath:sub(1, #pathMatch + 1) == pathMatch .. "." then
buttonColor = theme.errorBgColor
buttonTextColor = theme.errorTextColor
end
end
end
if type(v) == "table" or type(v) == "string" and v:sub(1,5) == "sound" then
local label = k
if isArray and arrayLabel then
local labelPath = fullPath .. "." .. arrayLabel
if labelPath:sub(1,1) == "." then
labelPath = labelPath:sub(2)
end
if type(v) == "table" and configDiffs[labelPath] then
label = configDiffs[labelPath]
elseif type(v) == "table" and subConfig[tonumber(k)] and subConfig[tonumber(k)][arrayLabel] then
label = subConfig[tonumber(k)][arrayLabel]
end
end
--print(textutils.serialize(configDiffs))
if textY >= props.y + 1 and textY <= props.y + props.height then
table.insert(elements, BasicButton {
key = "config-"..k,
display = props.display,
align = "left",
text = " " .. label .. " ",
x = props.x + xOffset,
y = textY,
color = buttonTextColor,
bg = buttonColor,
width = math.min(#label+2, props.width - 2) - xOffset,
onClick = function()
props.terminalState.scroll = 0
table.insert(paths, k)
props.terminalState.configPath = table.concat(paths, ".")
end,
})
end
textY = textY + 1
if textY >= props.y + 1 and textY <= props.y + props.height then
table.insert(elements, BasicButton {
key = "configarrow-"..k,
display = props.display,
align = "center",
text = "-->",
x = props.x + xOffset,
y = textY,
color = buttonTextColor,
bg = buttonColor,
width = math.min(#label+2, props.width - 2) - xOffset,
onClick = function()
props.terminalState.scroll = 0
table.insert(paths, k)
props.terminalState.configPath = table.concat(paths, ".")
end,
})
end
elseif type(v) == "string" then
_, _, typeDef, typeName = v:find("^(%w+<.+>)%??: (.+)$")
if textY >= props.y + 1 and textY <= props.y + props.height then
local nameText = k .. ": " .. (typeName or v)
if fullPath:find("palette") then
local field = configHelpers.getColorName(k)
nameText = field .. ": color code"
end
table.insert(elements, BasicButton {
key = "config-key-"..k,
display = props.display,
align = "left",
text = nameText,
x = props.x + xOffset,
y = textY,
color = buttonTextColor,
bg = buttonColor,
width = props.width - 1 - xOffset,
onClick = function()
-- props.terminalState.scroll = 0
-- table.insert(paths, k)
-- props.terminalState.configPath = table.concat(paths, ".")
end,
})
end
textY = textY + 1
if textY >= props.y + 1 and textY <= props.y + props.height then
if v:sub(1, 6) == "string" or v:sub(1,5) == "regex" or v:sub(1,4) == "file"
or v:sub(1,5) == "modem" or v:sub(1,7) == "speaker" or v:sub(1,5) == "chest" then
local inputStateValue = configDiffs[fullPath] or subConfig[k] or subConfig[tonumber(k)]
if inputStateValue == "%nil%" then
inputStateValue = nil
end
table.insert(elements, TextInput {
key = "config-value-"..fullPath,
display = props.display,
align = "left",
x = props.x + xOffset,
y = textY,
color = theme.inputTextColor,
bg = theme.inputBgColor,
height = 1,
width = props.width - 1 - xOffset,
inputState = { value = configDiffs[fullPath] or subConfig[k] },
onChange = function(value)
if value == "" or value == nil then
value = "%nil%"
end
configDiffs[fullPath] = value
setConfigDiffs(configDiffs)
setUpdates(updates + 1)
local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves)
setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix))
end,
})
elseif v:sub(1, 7) == "boolean" then
local toggleStartValue = configDiffs[fullPath]
if toggleStartValue == nil then
toggleStartValue = subConfig[k]
end
table.insert(elements, Rect {
key = "config-value-" .. fullPath .. "-bg",
display = props.display,
x = (props.x*2)-1 + xOffset*2,
y = (textY*3)-2,
color = buttonColor,
width = (props.width * 2) - 2,
height = 3,
})
table.insert(elements, Toggle {
key = "config-value-"..k,
display = props.display,
x = (props.x*2)-1 + xOffset,
y = (textY*3)-2,
color = theme.toggleColor,
bg = theme.toggleBgColor,
onColor = theme.toggleOnColor,
offColor = theme.toggleOffColor,
width = 2 * 6,
height = 2,
inputState = { value = toggleStartValue },
onChange = function(value)
configDiffs[fullPath] = value
setConfigDiffs(configDiffs)
setUpdates(updates + 1)
local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves)
setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix))
end,
})
elseif v:sub(1, 6) == "number" then
table.insert(elements, Rect {
key = "config-value-" .. fullPath .. "-bg",
display = props.display,
x = (props.x*2)-1 + 12*2 + xOffset*2,
y = (textY*3)-2,
color = buttonColor,
width = (props.width * 2) - 2 - 12*2 - xOffset*2,
height = 3,
})
local inputType = "number"
if fullPath:find("palette") then
inputType = "colorpicker"
end
table.insert(elements, TextInput {
key = "config-value-"..k,
display = props.display,
type = inputType,
align = "left",
x = props.x + xOffset,
y = textY,
color = theme.inputTextColor,
bg = theme.inputBgColor,
height = 1,
width = 12,
inputState = { value = configDiffs[fullPath] or subConfig[k] or subConfig[tonumber(k)] },
onChange = function(value)
configDiffs[fullPath] = value
setConfigDiffs(configDiffs)
setUpdates(updates + 1)
local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves)
setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix))
end,
})
elseif v:sub(1, 5) == "color" then
lastSelect = true
table.insert(elements, Select {
key = "config-value-"..fullPath,
display = props.display,
x = props.x + xOffset,
y = textY,
color = theme.inputTextColor,
bg = theme.inputBgColor,
scrollbarColor = theme.scrollbarColor,
toggleColor = theme.toggleColor,
height = props.y + props.height - textY,
width = props.width - 1 - xOffset,
inputState = { value = configDiffs[fullPath] or subConfig[k] },
options = {
{ value = colors.black, text = "Black" },
{ value = colors.blue, text = "Blue" },
{ value = colors.purple, text = "Purple" },
{ value = colors.green, text = "Green" },
{ value = colors.brown, text = "Brown" },
{ value = colors.gray, text = "Gray" },
{ value = colors.lightGray, text = "Light Gray" },
{ value = colors.red, text = "Red" },
{ value = colors.orange, text = "Orange" },
{ value = colors.yellow, text = "Yellow" },
{ value = colors.lime, text = "Lime" },
{ value = colors.cyan, text = "Cyan" },
{ value = colors.magenta, text = "Magenta" },
{ value = colors.pink, text = "Pink" },
{ value = colors.lightBlue, text = "Light Blue" },
{ value = colors.white, text = "White" },
},
onChange = function(value)
configDiffs[fullPath] = value
setConfigDiffs(configDiffs)
setUpdates(updates + 1)
local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves)
setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix))
end,
})
elseif typeDef and typeDef:sub(1, 5) == "enum<" and typeDef:sub(-1) == ">" then
lastSelect = true
local enum = typeDef:sub(6, -2)
local options = {}
for enumValue in enum:gmatch("[^|]+") do
enumValue = enumValue:sub(enumValue:find("'(.*)'")):sub(2, -2)
table.insert(options, { value = enumValue, text = enumValue })
end
table.insert(elements, Select {
key = "config-value-"..fullPath,
display = props.display,
x = props.x + xOffset,
y = textY,
color = theme.inputTextColor,
bg = theme.inputBgColor,
scrollbarColor = theme.scrollbarColor,
toggleColor = theme.toggleColor,
height = props.y + props.height - textY,
width = props.width - 1 - xOffset,
inputState = { value = configDiffs[fullPath] or subConfig[k] },
options = options,
onChange = function(value)
configDiffs[fullPath] = value
setConfigDiffs(configDiffs)
setUpdates(updates + 1)
local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves)
setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix))
end,
})
end
end
end
elementY = elementY + 3
if textY + 1 > props.y + props.height then
break
end
end
if subSchema.__type and subSchema.__type:sub(1,5) == "array" and subSchema.__entry then
if not subSchema.__max or (numKeys-1) < subSchema.__max then
-- Show add new button
-- (With red exclamation if min not met)
local minMet = not subSchema.__min or (numKeys-1) >= subSchema.__min
local buttonText = "Add New"
if not minMet then
buttonText = buttonText .. " (Needs " .. tostring(subSchema.__min - numKeys + 1) .. " more)"
end
local buttonColor = minMet and theme.buttonColor or theme.errorBgColor
local buttonTextColor = minMet and theme.buttonTextColor or theme.errorTextColor
textY = props.y + 1 + elementY - props.terminalState.scroll
if textY >= props.y + 1 and textY <= props.y + props.height then
table.insert(elements, BasicButton {
key = "config-add-"..props.terminalState.configPath,
display = props.display,
x = props.x,
y = textY,
align = "center",
color = buttonTextColor,
bg = buttonColor,
height = 1,
width = #buttonText + 2,
text = buttonText,
onClick = function()
arrayAdds[props.terminalState.configPath .. "." .. tostring(numKeys)] = subSchema.__entry
setArrayAdds(arrayAdds)
setUpdates(updates + 1)
local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves)
setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix))
end,
})
end
if textY+1 >= props.y + 1 and textY+1 <= props.y + props.height then
table.insert(elements, BasicButton {
key = "config-add2-"..props.terminalState.configPath,
display = props.display,
x = props.x,
y = textY+1,
align = "center",
color = buttonTextColor,
bg = buttonColor,
height = 1,
width = #buttonText + 2,
text = "",
onClick = function()
arrayAdds[props.terminalState.configPath .. "." .. tostring(numKeys)] = true
setArrayAdds(arrayAdds)
setUpdates(updates + 1)
local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves)
setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix))
end,
})
end
end
end
if lastSelect then
props.terminalState.maxScroll = props.terminalState.maxScroll + 3
end
if props.terminalState.maxScroll > 0 then
table.insert(elements, Scrollbar {
key = "sb",
display = props.display,
x = (props.x + props.width - 1)*2 - 1,
y = (props.y*3)-2,
width = 2,
height = props.height * 3,
areaHeight = props.height * 3,
scroll = props.terminalState.scroll * 3,
maxScroll = props.terminalState.maxScroll * 3,
color = theme.scrollbarColor,
bg = theme.scrollbarBgColor,
})
end
elseif subSchema.__type == "array" then
-- Dealing with an array
end
end
if saveModalOpen then
local modalWidth = math.min(props.width, 30)
local modalHeight = 6
local modalText = "Are you sure\nyou want to save?"
if errors and #errors > 0 then
modalText = "Your config is incomplete,\nare you sure you want to\nsave?"
end
table.insert(elements, Alert {
key = "save-modal",
display = props.display,
x = math.floor(props.x + (props.width/2) - (modalWidth/2)),
y = math.floor(props.y + (props.height/2) - (modalHeight/2)),
align = "center",
width = modalWidth,
height = modalHeight,
text = modalText,
bg = theme.modalBgColor,
color = theme.modalTextColor,
buttonColor = theme.inactiveButtonColor,
buttonTextColor = theme.inactiveButtonTextColor,
borderColor = theme.modalBorderColor,
onConfirm = function()
local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves)
local newEditConfig = configHelpers.getNewConfig(props.editConfig, configDiffs, arrayAdds, arrayRemoves)
setSaveModalOpen(false)
setArrayAdds({})
setArrayRemoves({})
setConfigDiffs({})
setUpdates(updates + 1)
if props.onSave then
props.onSave(newConfig, newEditConfig)
end
end,
onCancel = function()
setSaveModalOpen(false)
end,
})
end
-- local logMessageY = props.height
-- for i = 1, math.min(#props.errors, props.height) do
-- local logMessage = "[" .. props.errors[i].path .. "] " .. props.errors[i].error
-- local numLines = math.ceil(#logMessage / props.width)
-- if logMessageY - numLines + 1 < 0 then
-- break
-- end
-- for j = 1, numLines do
-- local line = logMessage:sub((j - 1) * props.width + 1, j * props.width)
-- table.insert(elements, BasicText {
-- key = "errors-"..tostring(i).."-"..tostring(j),
-- display = props.display,
-- align = "left",
-- text = line,
-- x = 1,
-- y = logMessageY - numLines + j + 1,
-- color = props.buttonTextColor,
-- bg = props.buttonBgColor,
-- })
-- end
-- logMessageY = logMessageY - numLines
-- end
return elements, { canvas = { canvas, props.x*2-1, props.y*3-2 } }
end)
end
preload["components.Button"] = function(...)
local Solyd = require("modules.solyd")
local hooks = require("modules.hooks")
local useBoundingBox = hooks.useBoundingBox
local BigText = require("components.BigText")
local bigFont = require("fonts.bigfont")
return Solyd.wrapComponent("Button", function(props)
-- local canvas = Solyd.useContext("canvas")
-- local canvas = useCanvas()
return BigText {
display = props.display,
align = props.align,
text = props.text,
x = props.x,
y = props.y,
bg = props.bg,
color = props.color,
width = props.width,
}, {
-- canvas = canvas,
aabb = useBoundingBox(props.x, props.y, props.width or bigFont:getWidth(props.text), bigFont.height+6, props.onClick),
}
end)
end
preload["components.BigText"] = function(...)
local Solyd = require("modules.solyd")
local hooks = require("modules.hooks")
local useCanvas = hooks.useCanvas
local bigFont = require("fonts.bigfont")
return Solyd.wrapComponent("BigText", function(props)
local fw = props.width or bigFont:getWidth(props.text)+2
local bgHeight = 6
local canvas = useCanvas(props.display, fw, bigFont.height+bgHeight)--Solyd.useContext("canvas")
Solyd.useEffect(function()
if props.bg then
for x = 1, fw do
for y = 1, bigFont.height+bgHeight do
canvas:setPixel(x, y, props.bg)
end
end
end
local cx = 0
if props.width then
if props.align == "center" then
cx = math.floor((props.width - bigFont:getWidth(props.text)) / 2)
elseif props.align == "right" then
cx = props.width - bigFont:getWidth(props.text) - 2
end
end
bigFont:write(canvas, props.text, math.max(2, cx), bgHeight-3, props.color or colors.white)
return function()
canvas:markRect(1, 1, fw, bigFont.height+bgHeight)
end
end, { canvas, props.display, props.align, props.text, props.color, props.bg, fw })
local x = props.right and props.x-canvas.width+1 or props.x
return nil, { canvas = { canvas, x, props.y } }
end)
end
preload["components.BasicText"] = function(...)
local Solyd = require("modules.solyd")
local hooks = require("modules.hooks")
local useTextCanvas = hooks.useTextCanvas
return Solyd.wrapComponent("BasicText", function(props)
local fw = props.width or #props.text
local canvas = useTextCanvas(props.display, fw*2, 1)
Solyd.useEffect(function()
local text = props.text
if props.width then
if props.align == "center" then
local leftPad = math.floor((props.width - #text) / 2)
local rightPad = props.width - #text - leftPad
text = string.rep(" ", leftPad) .. text .. string.rep(" ", rightPad)
elseif props.align == "right" then
text = string.rep(" ", props.width - #text) .. text
else
text = text .. string.rep(" ", props.width - #text)
end
end
if props.width and #text > props.width then
text = text:sub(1, props.width)
end
canvas:write(text, 1, 1, props.color or colors.white, props.bg or colors.black)
return function()
canvas:markText(text, 1, 1)
end
end, { canvas, props.display, props.align, props.text, props.color, props.bg, fw })
local x = props.right and props.x-canvas.width+1 or props.x
return nil, { canvas = { canvas, x*2-1, props.y*3-2 } }
end)
end
preload["components.BasicButton"] = function(...)
local Solyd = require("modules.solyd")
local hooks = require("modules.hooks")
local useBoundingBox = hooks.useBoundingBox
local BasicText = require("components.BasicText")
return Solyd.wrapComponent("BasicButton", function(props)
-- local canvas = Solyd.useContext("canvas")
-- local canvas = useCanvas()
return BasicText {
display = props.display,
align = props.align,
text = props.text,
x = props.x,
y = props.y,
bg = props.bg,
color = props.color,
width = props.width,
}, {
-- canvas = canvas,
aabb = useBoundingBox((props.x*2)-1, (props.y*3)-2, (props.width or #props.text)*2, 3, props.onClick, props.onScroll),
}
end)
end
preload["components.Alert"] = function(...)
local Solyd = require("modules.solyd")
local hooks = require("modules.hooks")
local useBoundingBox = hooks.useBoundingBox
local BasicText = require("components.BasicText")
local BasicButton = require("components.BasicButton")
return Solyd.wrapComponent("Alert", function(props)
local text = props.text
local lines = {}
local textWidth = props.width - 2
for line in text:gmatch("[^\n]+") do
for j = 1, math.ceil(#line / textWidth) do
table.insert(lines, line:sub((j-1)*textWidth, (j)*textWidth - 1))
end
end
local elements = {}
table.insert(elements, BasicText {
key = props.key .. "-header",
display = props.display,
align = "left",
text = "",
x = props.x,
y = props.y,
bg = props.borderColor,
color = props.color,
width = props.width,
})
local lineY = 0
for i = 1, #lines do
if (i+1) >= props.height then
break
end
lineY = lineY + 1
table.insert(elements, BasicText {
key = props.key .. "-line-" .. i,
display = props.display,
align = props.align,
text = lines[i],
x = props.x + 1,
y = props.y + i,
bg = props.bg,
color = props.color,
width = props.width - 2,
})
table.insert(elements, BasicText {
key = props.key .. "-filler-bl-" .. i,
display = props.display,
align = "left",
text = " ",
x = props.x,
y = props.y + i,
bg = props.borderColor,
color = props.color,
width = 1,
})
table.insert(elements, BasicText {
key = props.key .. "-filler-br-" .. i,
display = props.display,
align = "left",
text = " ",
x = props.x + props.width - 1,
y = props.y + i,
bg = props.borderColor,
color = props.color,
width = 1,
})
end
for i = lineY + 1, props.height - 2 do
table.insert(elements, BasicText {
key = props.key .. "-filler-" .. i,
display = props.display,
align = "left",
text = "",
x = props.x + 1,
y = props.y + i,
bg = props.bg,
color = props.color,
width = props.width - 2,
})
table.insert(elements, BasicText {
key = props.key .. "-filler-bl-" .. i,
display = props.display,
align = "left",
text = " ",
x = props.x,
y = props.y + i,
bg = props.borderColor,
color = props.color,
width = 1,
})
table.insert(elements, BasicText {
key = props.key .. "-filler-br-" .. i,
display = props.display,
align = "left",
text = " ",
x = props.x + props.width - 1,
y = props.y + i,
bg = props.borderColor,
color = props.color,
width = 1,
})
end
table.insert(elements, BasicText {
key = props.key .. "-footer",
display = props.display,
align = "left",
text = "",
x = props.x,
y = props.y + props.height - 1,
bg = props.borderColor,
color = props.color,
width = props.width,
})
local cancelText = props.cancelText or "Cancel"
local confirmText = props.confirmText or "Confirm"
local buttonsWidth = #cancelText + #confirmText + 2
local buttonsX = math.floor(props.x + (props.width - buttonsWidth) / 2)
table.insert(elements, BasicButton {
key = props.key .. "-cancel",
display = props.display,
align = "left",
text = cancelText,
x = buttonsX,
y = props.y + props.height - 2,
bg = props.buttonColor,
color = props.buttonTextColor,
width = props.buttonsWidth,
onClick = props.onCancel,
})
table.insert(elements, BasicButton {
key = props.key .. "-confirm",
display = props.display,
align = "left",
text = confirmText,
x = buttonsX + #cancelText + 2,
y = props.y + props.height - 2,
bg = props.buttonColor,
color = props.buttonTextColor,
width = props.buttonsWidth,
onClick = props.onConfirm,
})
return elements
end)
end
preload["Krypton"] = function(...)
local Krypton = {
__opaque = true,
node = "https://krist.dev/"
}
local Krypton_mt = { __index = Krypton }
--[[
Make addresses
]]
local g = string.gsub
sha256 = loadstring(g(g(g(g(g(g(g(g('Sa=XbandSb=XbxWSc=XlshiftSd=unpackSe=2^32SYf(g,h)Si=g/2^hSj=i%1Ui-j+j*eVSYk(l,m)Sn=l/2^mUn-n%1VSo={0x6a09e667Tbb67ae85T3c6ef372Ta54ff53aT510e527fT9b05688cT1f83d9abT5be0cd19}Sp={0x428a2f98T71374491Tb5c0fbcfTe9b5dba5T3956c25bT59f111f1T923f82a4Tab1c5ed5Td807aa98T12835b01T243185beT550c7dc3T72be5d74T80deb1feT9bdc06a7Tc19bf174Te49b69c1Tefbe4786T0fc19dc6T240ca1ccT2de92c6fT4a7484aaT5cb0a9dcT76f988daT983e5152Ta831c66dTb00327c8Tbf597fc7Tc6e00bf3Td5a79147T06ca6351T14292967T27b70a85T2e1b2138T4d2c6dfcT53380d13T650a7354T766a0abbT81c2c92eT92722c85Ta2bfe8a1Ta81a664bTc24b8b70Tc76c51a3Td192e819Td6990624Tf40e3585T106aa070T19a4c116T1e376c08T2748774cT34b0bcb5T391c0cb3T4ed8aa4aT5b9cca4fT682e6ff3T748f82eeT78a5636fT84c87814T8cc70208T90befffaTa4506cebTbef9a3f7Tc67178f2}SYq(r,q)if e-1-r[1]<q then r[2]=r[2]+1;r[1]=q-(e-1-r[1])-1 else r[1]=r[1]+qVUrVSYs(t)Su=#t;t[#t+1]=0x80;while#t%64~=56Zt[#t+1]=0VSv=q({0,0},u*8)fWw=2,1,-1Zt[#t+1]=a(k(a(v[w]TFF000000),24)TFF)t[#t+1]=a(k(a(v[w]TFF0000),16)TFF)t[#t+1]=a(k(a(v[w]TFF00),8)TFF)t[#t+1]=a(v[w]TFF)VUtVSYx(y,w)Uc(y[w]W0,24)+c(y[w+1]W0,16)+c(y[w+2]W0,8)+(y[w+3]W0)VSYz(t,w,A)SB={}fWC=1,16ZB[C]=x(t,w+(C-1)*4)VfWC=17,64ZSD=B[C-15]SE=b(b(f(B[C-15],7),f(B[C-15],18)),k(B[C-15],3))SF=b(b(f(B[C-2],17),f(B[C-2],19)),k(B[C-2],10))B[C]=(B[C-16]+E+B[C-7]+F)%eVSG,h,H,I,J,j,K,L=d(A)fWC=1,64ZSM=b(b(f(J,6),f(J,11)),f(J,25))SN=b(a(J,j),a(Xbnot(J),K))SO=(L+M+N+p[C]+B[C])%eSP=b(b(f(G,2),f(G,13)),f(G,22))SQ=b(b(a(G,h),a(G,H)),a(h,H))SR=(P+Q)%e;L,K,j,J,I,H,h,G=K,j,J,(I+O)%e,H,h,G,(O+R)%eVA[1]=(A[1]+G)%e;A[2]=(A[2]+h)%e;A[3]=(A[3]+H)%e;A[4]=(A[4]+I)%e;A[5]=(A[5]+J)%e;A[6]=(A[6]+j)%e;A[7]=(A[7]+K)%e;A[8]=(A[8]+L)%eUAVUY(t)t=t W""t=type(t)=="string"and{t:byte(1,-1)}Wt;t=s(t)SA={d(o)}fWw=1,#t,64ZA=z(t,w,A)VU("%08x"):rep(8):format(d(A))V',"S"," local "),"T",",0x"),"U"," return "),"V"," end "),"W","or "),"X","bit32."),"Y","function "),"Z"," do "))()
function makeaddressbyte(byte)
local byte = 48 + math.floor(byte / 7)
return string.char(byte + 39 > 122 and 101 or byte > 57 and byte + 39 or byte)
end
function Krypton:makev2address(key)
local protein = {}
local stick = sha256(sha256(key))
local n = 0
local link = 0
local v2 = "k"
if self.currency and self.currency.address_prefix then
v2 = self.currency.address_prefix
end
repeat
if n < 9 then protein[n] = string.sub(stick,0,2)
stick = sha256(sha256(stick)) end
n = n + 1
until n == 9
n = 0
repeat
link = tonumber(string.sub(stick,1+(2*n),2+(2*n)),16) % 9
if string.len(protein[link]) ~= 0 then
v2 = v2 .. makeaddressbyte(tonumber(protein[link],16))
protein[link] = ''
n = n + 1
else
stick = sha256(stick)
end
until n == 9
return v2
end
function Krypton:toKristWalletFormat(passphrase)
return sha256("KRISTWALLET"..passphrase).."-000"
end
--[[
Basic GET and POST requests
]]
function Krypton:get(endpoint)
local url = self.node .. endpoint
local response, err = http.get(url)
if not response then
error(err, 3)
end
local body = response.readAll()
response.close()
local data = textutils.unserializeJSON(body)
if not data.ok then
if (data.error ~= "invalid_parameter") then
error(data.error, 3)
else
error({data.error, data.parameter}, 3)
end
end
return data
end
function Krypton:post(endpoint, data)
local url = self.node .. endpoint
local response, err = http.post(url, textutils.serializeJSON(data),{["content-type"]="application/json"})
if not response then
error(err, 3)
end
local body = response.readAll()
response.close()
local data = textutils.unserializeJSON(body)
if not data.ok then
if (data.error ~= "invalid_parameter") then
error(data.error, 3)
else
error({data.error, data.parameter}, 3)
end
end
return data
end
--[[
Krist API Endpoints
]]
-- Miscellaneous endpoints
function Krypton:getInfo()
return self:get("motd")
end
function Krypton:getName(name)
return self:get("names/" .. textutils.urlEncode(name))
end
-- Websocket Endpoints
function Krypton:startWs(privateKey)
local data = self:post("ws/start", {privatekey = privateKey})
if not data.url then
error("Failed to get websocket URL", 2)
end
return data.url
end
--[[
Constructor
]]
function Krypton.new(props)
props = props or {}
local self = setmetatable(props, Krypton_mt)
local info = self:getInfo()
self.currency = info.currency
return self
end
--[[
Websockets
]]
local KryptonWS = {}
local KryptonWS_mt = { __index = KryptonWS }
function Krypton:connect()
if self.ws then
return self.ws
end
self.ws = KryptonWS.new({
krypton = self
})
return self.ws
end
function KryptonWS:connect()
local url = self.krypton:startWs(self.krypton.privateKey)
self.ws = http.websocket(url)
self.id = 1
end
function KryptonWS:reconnect()
self.ws.close()
self:connect()
end
function KryptonWS:disconnect()
self.ws.close()
self.ws = nil
end
function KryptonWS:listen()
while true do
if self.ws then
local success, err = pcall(function()
local response, binary = self.ws.receive(15)
if not self.ws then
-- We've been disconnected, stop listening
return "break"
end
if not response then
-- Didn't get keepalive, reconnect
self:reconnect()
end
--print((self.krypton.id or self.krypton.node) .. " <- " .. response)
local data = textutils.unserializeJSON(response)
local eventType = data.type
if eventType and eventType == "event" then
local event = data
event.source = self.krypton.id or self.krypton.node
os.queueEvent(event.event, event)
elseif not eventType then
local event = data
event.source = self.krypton.id or self.krypton.node
if data.ok then
os.queueEvent("krypton_response", event)
else
os.queueEvent("krypton_error", event)
end
end
end)
if success and err == "break" then
break
elseif err then
if err == "Terminated" then
break
end
print("Got error receiving response from " .. (self.krypton.id or self.krypton.node) .. " reconnecting")
self:reconnect()
end
else
sleep(0.1)
end
end
end
function KryptonWS:send(type, data)
data = data or {}
data.id = self.id
data.type = type
self.ws.send(textutils.serializeJSON(data))
self.id = self.id + 1
return self.id - 1
end
-- Requires authed websocket
function KryptonWS:makeTransaction(to, amount, metadata)
return self:send("make_transaction", {to = to, amount = amount, metadata = metadata})
end
function KryptonWS:getAddress(address, fetchNames)
return self:send("address", {address = address, fetch_names = fetchNames})
end
function KryptonWS:getSelf()
return self:send("me")
end
function KryptonWS:getSubscriptions()
return self:send("get_subscription_level")
end
function KryptonWS:logout()
return self:send("logout")
end
function KryptonWS:login(privateKey)
privateKey = privateKey or self.krypton.privateKey
return self:send("login", {privatekey = privateKey})
end
function KryptonWS:subscribe(event)
return self:send("subscribe", {event = event})
end
function KryptonWS:unsubscribe(event)
return self:send("unsubscribe", {event = event})
end
function KryptonWS.new(props)
local self = setmetatable({}, KryptonWS_mt)
self.krypton = props.krypton
self:connect()
return self
end
return Krypton
end
preload["DefaultLayout"] = function(...)
--- Imports
local _ = require("util.score")
local sound = require("util.sound")
local eventHook = require("util.eventHook")
local renderHelpers = require("util.renderHelpers")
local Display = require("modules.display")
local Solyd = require("modules.solyd")
local hooks = require("modules.hooks")
local useCanvas = hooks.useCanvas
local Button = require("components.Button")
local BasicButton = require("components.BasicButton")
local SmolButton = require("components.SmolButton")
local BigText = require("components.BigText")
local bigFont = require("fonts.bigfont")
local SmolText = require("components.SmolText")
local smolFont = require("fonts.smolfont")
local BasicText = require("components.BasicText")
local Rect = require("components.Rect")
local RenderCanvas = require("components.RenderCanvas")
local Core = require("core.ShopState")
local Pricing = require("core.Pricing")
local ShopRunner = require("core.ShopRunner")
local ConfigValidator = require("core.ConfigValidator")
local loadRIF = require("modules.rif")
local function render(canvas, display, props, theme, version)
local elements = {}
local selectedProduct, setSelectedProduct = Solyd.useState("")
local categories = renderHelpers.getCategories(props.shopState.products)
local selectedCategory = props.shopState.selectedCategory
local currencyEndX = 3
if #props.configState.config.currencies > 1 then
for i = 1, #props.configState.config.currencies do
local symbol = renderHelpers.getCurrencySymbol(props.configState.config.currencies[i], "large")
local symbolSize = bigFont:getWidth(symbol)+6
currencyEndX = currencyEndX + symbolSize + 2
end
end
local categoryX = display.bgCanvas.width - 2
if #categories > 1 then
for i = #categories, 1, -1 do
local category = categories[i]
local categoryName = category.name
if i == selectedCategory then
categoryName = "[" .. categoryName .. "]"
end
local categoryWidth = smolFont:getWidth(categoryName)+6
categoryX = categoryX - categoryWidth - 2
end
end
local headerCx = math.floor((display.bgCanvas.width - bigFont:getWidth(props.configState.config.branding.title)) / 2)
local header
-- TODO: Change header font size based on width
local headerHeight = 15
if theme.formatting.headerAlign == "center" and headerCx < currencyEndX and #categories == 1 then
table.insert(elements, Rect { display=display, x=1, y=1, width=currencyEndX, height=bigFont.height+6, color=theme.colors.headerBgColor })
header = BigText { display=display, text=props.configState.config.branding.title, x=currencyEndX, y=1, align="left", bg=theme.colors.headerBgColor, color = theme.colors.headerColor, width=display.bgCanvas.width }
elseif theme.formatting.headerAlign == "center" and headerCx+bigFont:getWidth(props.configState.config.branding.title) > categoryX and #categories > 1 then
table.insert(elements, Rect { display=display, x=categoryX, y=1, width=display.bgCanvas.width-categoryX+1, height=bigFont.height+6, color=theme.colors.headerBgColor })
header = BigText { display=display, text=props.configState.config.branding.title, x=1, y=1, align="right", bg=theme.colors.headerBgColor, color = theme.colors.headerColor, width=categoryX-1 }
else
header = BigText { display=display, text=props.configState.config.branding.title, x=1, y=1, align=theme.formatting.headerAlign, bg=theme.colors.headerBgColor, color = theme.colors.headerColor, width=display.bgCanvas.width }
end
if props.configState.config.branding.subtitle then
table.insert(elements, BasicText {
display = display,
text = props.configState.config.branding.subtitle,
x = 1,
y = 6,
align = theme.formatting.subtitleAlign,
bg = theme.colors.subtitleBgColor,
color = theme.colors.subtitleColor,
width = math.floor(display.bgCanvas.width / 2)
})
headerHeight = headerHeight + 3
end
table.insert(elements, header)
local footerHeight = 0
if props.configState.config.settings.showFooter then
local footerMessage
if props.shopState.selectedCurrency.name or not props.configState.config.lang.footerNoName then
footerMessage = props.configState.config.lang.footer
else
footerMessage = props.configState.config.lang.footerNoName
end
if props.shopState.selectedCurrency.name and footerMessage:find("%%name%%") then
footerMessage = footerMessage:gsub("%%name%%", props.shopState.selectedCurrency.name)
end
if footerMessage:find("%%addr%%") and props.shopState.selectedCurrency.host then
footerMessage = footerMessage:gsub("%%addr%%", props.shopState.selectedCurrency.host)
end
if footerMessage:find("%%version%%") then
footerMessage = footerMessage:gsub("%%version%%", version)
end
if selectedProduct and #selectedProduct > 0 and footerMessage:find("<item>") then
footerMessage = footerMessage:gsub("<item>", selectedProduct)
end
if props.shopState.selectedCurrency then
local footer
local footerSize = theme.formatting.footerSize
if footerSize == "auto" and bigFont:getWidth(footerMessage) < display.bgCanvas.width then
footerSize = "large"
elseif footerSize == "auto" and smolFont:getWidth(footerMessage) < display.bgCanvas.width then
footerSize = "medium"
elseif footerSize == "auto" then
footerSize = "small"
end
if footerSize == "large" then
footer = BigText { display=display, text=footerMessage, x=1, y=display.bgCanvas.height-bigFont.height-5, align=theme.formatting.footerAlign, bg=theme.colors.footerBgColor, color = theme.colors.footerColor, width=display.bgCanvas.width }
footerHeight = smolFont.height + 6
elseif footerSize == "medium" then
footer = SmolText { display=display, text=footerMessage, x=1, y=display.bgCanvas.height-smolFont.height-2, align=theme.formatting.footerAlign, bg=theme.colors.footerBgColor, color = theme.colors.footerColor, width=display.bgCanvas.width }
footerHeight = smolFont.height + 4
else
footer = BasicText { display=display, text=footerMessage, x=1, y=math.floor(display.bgCanvas.height/3), align=theme.formatting.footerAlign, bg=theme.colors.footerBgColor, color = theme.colors.footerColor, width=math.ceil(display.bgCanvas.width/2) }
footerHeight = smolFont.height + 4
end
table.insert(elements, footer)
end
end
local maxAddrWidth = 0
local maxQtyWidth = 0
local maxPriceWidth = 0
local maxNameWidth = 0
props.shopState.numCategories = #categories
local catName = "*"
local shopProducts = {}
if categories[selectedCategory] then
catName = categories[selectedCategory].name
shopProducts = renderHelpers.getDisplayedProducts(categories[selectedCategory].products, props.configState.config.settings, props.shopState.selectedCurrency)
end
local productsHeight = display.bgCanvas.height - headerHeight - footerHeight - 2
local heightPerProduct = math.floor(productsHeight / #shopProducts)
local layout
if theme.formatting.layout == "auto" then
if heightPerProduct >= 15 then
layout = "large"
elseif heightPerProduct >= 9 then
layout = "medium"
else
layout = "small"
end
else
layout = theme.formatting.layout
end
local currency = props.shopState.selectedCurrency
local currencySymbol = renderHelpers.getCurrencySymbol(currency, layout)
while #shopProducts > 0 and (maxAddrWidth == 0 or maxAddrWidth + maxQtyWidth + maxPriceWidth + maxNameWidth > display.bgCanvas.width - 3) do
if props.configState.config.theme.formatting.layout == "auto" and (maxAddrWidth + maxQtyWidth + maxPriceWidth + maxNameWidth > display.bgCanvas.width - 3) then
if layout == "large" then
layout = "medium"
maxAddrWidth = 0
maxQtyWidth = 0
maxPriceWidth = 0
maxNameWidth = 0
elseif layout == "medium" then
layout = "small"
maxAddrWidth = 0
maxQtyWidth = 0
maxPriceWidth = 0
maxNameWidth = 0
end
end
currencySymbol = renderHelpers.getCurrencySymbol(currency, layout)
for i = 1, #shopProducts do
local product = shopProducts[i]
local productAddr = product.address .. "@"
if props.shopState.selectedCurrency.name then
if layout == "small" then
if props.configState.config.settings.smallTextKristPayCompatability then
productAddr = product.address .. "@" .. props.shopState.selectedCurrency.name
else
productAddr = product.address .. "@ "
end
end
else
productAddr = product.address
end
product.quantity = product.quantity or 0
local productPrice = Pricing.getProductPrice(product, props.shopState.selectedCurrency)
if layout == "large" then
maxAddrWidth = math.max(maxAddrWidth, renderHelpers.getWidth(productAddr, layout)+2)
maxQtyWidth = math.max(maxQtyWidth, renderHelpers.getWidth(tostring(product.quantity), layout)+4+2)
maxPriceWidth = math.max(maxPriceWidth, renderHelpers.getWidth(tostring(productPrice) .. currencySymbol, layout)+2)
maxNameWidth = math.max(maxNameWidth, renderHelpers.getWidth(product.name, layout)+2)
elseif layout == "medium" then
maxAddrWidth = math.max(maxAddrWidth, renderHelpers.getWidth(productAddr, layout)+2)
maxQtyWidth = math.max(maxQtyWidth, renderHelpers.getWidth(tostring(product.quantity), layout)+4+2)
maxPriceWidth = math.max(maxPriceWidth, renderHelpers.getWidth(tostring(productPrice) .. currencySymbol, layout)+2)
maxNameWidth = math.max(maxNameWidth, renderHelpers.getWidth(product.name, layout)+2)
else
maxAddrWidth = math.max(maxAddrWidth, renderHelpers.getWidth(productAddr, layout)+1)
maxQtyWidth = math.max(maxQtyWidth, renderHelpers.getWidth(tostring(product.quantity), layout)+2)
maxPriceWidth = math.max(maxPriceWidth, renderHelpers.getWidth(tostring(productPrice) .. currencySymbol, layout)+1)
maxNameWidth = math.max(maxNameWidth, renderHelpers.getWidth(product.name, layout)+1)
end
end
if props.configState.config.theme.formatting.layout ~= "auto" or layout == "small" then
break
end
end
local startY = headerHeight + 1
local startTextY = math.ceil(headerHeight / 3) + 1
for i = 1, #shopProducts do
local product = shopProducts[i]
-- Display products in format:
-- <quantity> <name> <price> <address>
product.quantity = product.quantity or 0
local productPrice = Pricing.getProductPrice(product, props.shopState.selectedCurrency)
local qtyColor = theme.colors.normalQtyColor
if product.quantity == 0 then
qtyColor = theme.colors.outOfStockQtyColor
elseif product.quantity < 10 then
qtyColor = theme.colors.lowQtyColor
elseif product.quantity < 64 then
qtyColor = theme.colors.warningQtyColor
end
local productNameColor = theme.colors.productNameColor
if product.quantity == 0 then
productNameColor = theme.colors.outOfStockNameColor
end
local productAddr = product.address .. "@"
if props.shopState.selectedCurrency.name then
if layout == "small" then
if props.configState.config.settings.smallTextKristPayCompatability then
productAddr = product.address .. "@" .. props.shopState.selectedCurrency.name
else
productAddr = product.address .. "@ "
end
end
else
productAddr = product.address
end
local kristpayHelperText = props.shopState.selectedCurrency.host or ""
if props.shopState.selectedCurrency.name then
kristpayHelperText = product.address .. "@" .. props.shopState.selectedCurrency.name
end
local productBgColor = theme.colors.productBgColors[((i-1) % #theme.colors.productBgColors) + 1]
local productClickFunction = function()
setSelectedProduct(product.address)
if props.configState.eventHooks and props.configState.eventHooks.onProductSelected then
eventHook.execute(props.configState.eventHooks.onProductSelected, product, currency)
end
if props.configState.config.settings.playSounds then
sound.playSound(props.peripherals.speaker, props.configState.config.sounds.button)
end
end
if layout == "large" then
table.insert(elements, Button {
key="qty-"..catName..tostring(product.id),
display=display,
text=tostring(product.quantity),
x=1,
y=startY+((i-1)*15),
align="center",
bg=productBgColor,
color=qtyColor,
width=maxQtyWidth,
onClick=productClickFunction
})
table.insert(elements, Button {
key="name-"..catName..tostring(product.id),
display=display,
text=product.name,
x=maxQtyWidth+1,
y=startY+((i-1)*15),
align=theme.formatting.productNameAlign,
bg=productBgColor,
color=productNameColor,
width=display.bgCanvas.width-3-maxAddrWidth-maxPriceWidth-maxQtyWidth,
onClick=productClickFunction
})
table.insert(elements, Button {
key="price-"..catName..tostring(product.id),
display=display,
text=tostring(productPrice) .. currencySymbol,
x=display.bgCanvas.width-3-maxAddrWidth-maxPriceWidth,
y=startY+((i-1)*15),
align="right",
bg=productBgColor,
color=theme.colors.priceColor,
width=maxPriceWidth,
onClick=productClickFunction
})
table.insert(elements, Button {
key="addr-"..catName..tostring(product.id),
display=display,
text=productAddr,
x=display.bgCanvas.width-3-maxAddrWidth,
y=startY+((i-1)*15),
align="right",
bg=productBgColor,
color=theme.colors.addressColor,
width=maxAddrWidth+4,
onClick=productClickFunction
})
table.insert(elements, BasicText {
key="invis-" .. catName .. tostring(product.id),
display=display,
text=kristpayHelperText,
x=1,
y=startTextY+((i-1)*5),
align="center",
bg=productBgColor,
color=productBgColor,
width=#(kristpayHelperText)
})
elseif layout == "medium" then
table.insert(elements, SmolButton {
key="qty-"..catName..tostring(product.id),
display=display,
text=tostring(product.quantity),
x=1,
y=startY+((i-1)*9),
align="center",
bg=productBgColor,
color=qtyColor,
width=maxQtyWidth,
onClick=productClickFunction
})
table.insert(elements, SmolButton {
key="name-"..catName..tostring(product.id),
display=display,
text=product.name,
x=maxQtyWidth+1,
y=startY+((i-1)*9),
align=theme.formatting.productNameAlign,
bg=productBgColor,
color=productNameColor,
width=display.bgCanvas.width-3-maxAddrWidth-maxPriceWidth-maxQtyWidth,
onClick=productClickFunction
})
table.insert(elements, SmolButton {
key="price-"..catName..tostring(product.id),
display=display,
text=tostring(productPrice) .. currencySymbol,
x=display.bgCanvas.width-3-maxAddrWidth-maxPriceWidth,
y=startY+((i-1)*9),
align="right",
bg=productBgColor,
color=theme.colors.priceColor,
width=maxPriceWidth,
onClick=productClickFunction
})
table.insert(elements, SmolButton {
ey="addr-"..catName..tostring(product.id),
display=display,
text=productAddr,
x=display.bgCanvas.width-3-maxAddrWidth,
y=startY+((i-1)*9),
align="right",
bg=productBgColor,
color=theme.colors.addressColor,
width=maxAddrWidth+4,
onClick=productClickFunction
})
table.insert(elements, BasicText {
key="invis-" .. catName .. tostring(product.id),
display=display,
text=kristpayHelperText,
x=1,
y=startTextY+((i-1)*3),
align="center",
bg=productBgColor,
color=productBgColor,
width=#(kristpayHelperText)
})
else
table.insert(elements, BasicButton {
key="qty-"..catName..tostring(product.id),
display=display,
text=tostring(product.quantity),
x=1,
y=startTextY+((i-1)*1),
align="center",
bg=productBgColor,
color=qtyColor,
width=maxQtyWidth,
onClick=productClickFunction
})
table.insert(elements, BasicButton {
key="name-"..catName..tostring(product.id),
display=display,
text=product.name,
x=maxQtyWidth+1,
y=startTextY+((i-1)*1),
align=theme.formatting.productNameAlign,
bg=productBgColor,
color=productNameColor,
width=(display.bgCanvas.width/2)-1-maxAddrWidth-maxPriceWidth-maxQtyWidth,
onClick=productClickFunction
})
table.insert(elements, BasicButton {
key="price-"..catName..tostring(product.id),
display=display,
text=tostring(productPrice) .. currencySymbol,
x=(display.bgCanvas.width/2)-1-maxAddrWidth-maxPriceWidth,
y=startTextY+((i-1)*1),
align="right",
bg=productBgColor,
color=theme.colors.priceColor,
width=maxPriceWidth,
onClick=productClickFunction
})
table.insert(elements, BasicButton {
key="addr-"..catName..tostring(product.id),
display=display,
text=productAddr,
x=(display.bgCanvas.width/2)-1-maxAddrWidth,
y=startTextY+((i-1)*1),
align="right",
bg=productBgColor,
color=theme.colors.addressColor,
width=maxAddrWidth+2,
onClick=productClickFunction
})
end
end
local currencyX = 3
if #props.configState.config.currencies > 1 then
for i = 1, #props.configState.config.currencies do
local symbol = renderHelpers.getCurrencySymbol(props.configState.config.currencies[i], "large")
local symbolSize = bigFont:getWidth(symbol)+6+1
local bgColor = theme.colors.currencyBgColors[((i-1) % #theme.colors.currencyBgColors) + 1]
table.insert(elements, Button {
display = display,
align = "center",
text = symbol,
x = currencyX,
y = 1,
bg = bgColor,
color = theme.colors.currencyTextColor,
width = symbolSize,
onClick = function()
props.shopState.selectedCurrency = props.configState.config.currencies[i]
props.shopState.lastTouched = os.epoch("utc")
if props.configState.config.settings.playSounds then
sound.playSound(props.peripherals.speaker, props.configState.config.sounds.button)
end
end
})
currencyX = currencyX + symbolSize + 2
end
end
local categoryX = display.bgCanvas.width - 2
if #categories > 1 then
for i = #categories, 1, -1 do
local category = categories[i]
local categoryName = category.name
local categoryColor
if i == selectedCategory then
categoryColor = theme.colors.activeCategoryColor
categoryName = "[" .. categoryName .. "]"
else
categoryColor = theme.colors.categoryBgColors[((i-1) % #theme.colors.categoryBgColors) + 1]
end
local categoryWidth = smolFont:getWidth(categoryName)+6
categoryX = categoryX - categoryWidth - 2
table.insert(elements, SmolButton {
display = display,
align = "center",
text = categoryName,
x = categoryX,
y = 4,
bg = categoryColor,
color = theme.colors.categoryTextColor,
width = categoryWidth,
onClick = function()
props.shopState.selectedCategory = i
setSelectedProduct("")
props.shopState.lastTouched = os.epoch("utc")
if props.configState.config.settings.playSounds then
sound.playSound(props.peripherals.speaker, props.configState.config.sounds.button)
end
-- canvas:markRect(1, 16, canvas.width, canvas.height-16)
end
})
end
end
return elements
end
return render
end
return preload["radon"](...)