diff --git a/CardLayout.lua b/CardLayout.lua index c18ac1e..5423272 100644 --- a/CardLayout.lua +++ b/CardLayout.lua @@ -33,7 +33,7 @@ local categories = renderHelpers.getCategories(props.shopState.products) local selectedCategory = props.shopState.selectedCategory - local shopProducts = renderHelpers.getDisplayedProducts(categories[selectedCategory].products, props.config.settings) + local shopProducts = renderHelpers.getDisplayedProducts(categories[selectedCategory].products, props.configState.config.settings) local currency = props.shopState.selectedCurrency local headerSuffix = "" @@ -42,12 +42,12 @@ end local headerPadding = 2*6 local headerWidth - local headerText = currency.host + local headerText = currency.host or "" if currency.name then headerText = currency.name:gsub(headerSuffix .. "$", "") headerWidth = bigFont:getWidth(headerText) else - headerWidth = bigFont:getWidth(currency.host) + headerWidth = bigFont:getWidth(headerText) end local headerStartX = 1 local headerAlign = renderHelpers.getThemeSetting(theme, "formatting.headerAlign", layoutName) @@ -80,7 +80,7 @@ table.insert(elements, suffix) end - local subHeaderWidth = math.max( (display.bgCanvas.width / 2) / 2, #props.config.branding.title) + local subHeaderWidth = math.max( (display.bgCanvas.width / 2) / 2, #props.configState.config.branding.title) local subheaderStartX = 1 if headerAlign == "center" then subheaderStartX = math.floor(((display.bgCanvas.width / 2) - subHeaderWidth) / 2) @@ -89,7 +89,7 @@ end local subHeader = BasicText { display = display, - text = props.config.branding.title, + text = props.configState.config.branding.title, x = subheaderStartX, y = 6 + 2, align = headerAlign, @@ -315,13 +315,13 @@ end -- Currencies - if #props.config.currencies > 1 then + if #props.configState.config.currencies > 1 then local currencyBgColors = renderHelpers.getThemeSetting(theme, "colors.currencyBgColors", layoutName) local maxCurrencyLeftX = math.floor( ((subheaderStartX*2) - 3) / 2) local minCurrencyRightX = math.ceil( (((subheaderStartX+subHeaderWidth)*2) + 3) / 2) local currencyX = 2 - for i = 1, #props.config.currencies do - local displayCurrency = props.config.currencies[i] + for i = 1, #props.configState.config.currencies do + local displayCurrency = props.configState.config.currencies[i] local displaySymbol = " " .. renderHelpers.getCurrencySymbol(displayCurrency, "small") .. " " local currencyBgColor = currencyBgColors[((i-1) % #currencyBgColors) + 1] if currencyX + #displaySymbol > maxCurrencyLeftX then @@ -336,10 +336,10 @@ bg = currencyBgColor, color = renderHelpers.getThemeSetting(theme, "colors.currencyTextColor", layoutName), onClick = function() - props.shopState.selectedCurrency = props.config.currencies[i] + props.shopState.selectedCurrency = props.configState.config.currencies[i] props.shopState.lastTouched = os.epoch("utc") - if props.config.settings.playSounds then - sound.playSound(props.speaker, props.config.sounds.button) + if props.configState.config.settings.playSounds then + sound.playSound(props.peripherals.speaker, props.configState.config.sounds.button) end end }) @@ -379,8 +379,8 @@ onClick = function() props.shopState.selectedCategory = i props.shopState.lastTouched = os.epoch("utc") - if props.config.settings.playSounds then - sound.playSound(props.speaker, props.config.sounds.button) + if props.configState.config.settings.playSounds then + sound.playSound(props.peripherals.speaker, props.configState.config.sounds.button) end end }) @@ -388,12 +388,12 @@ end end - if props.config.settings.showFooter then + if props.configState.config.settings.showFooter then local footerMessage - if props.shopState.selectedCurrency.name or not props.config.lang.footerNoName then - footerMessage = props.config.lang.footer + if props.shopState.selectedCurrency.name or not props.configState.config.lang.footerNoName then + footerMessage = props.configState.config.lang.footer else - footerMessage = props.config.lang.footerNoName + 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) diff --git a/DefaultLayout.lua b/DefaultLayout.lua index 424643a..c566f2c 100644 --- a/DefaultLayout.lua +++ b/DefaultLayout.lua @@ -34,9 +34,9 @@ local selectedCategory = props.shopState.selectedCategory local currencyEndX = 3 - if #props.config.currencies > 1 then - for i = 1, #props.config.currencies do - local symbol = renderHelpers.getCurrencySymbol(props.config.currencies[i], "large") + 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 @@ -55,33 +55,33 @@ end end - local headerCx = math.floor((display.bgCanvas.width - bigFont:getWidth(props.config.branding.title)) / 2) + 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 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.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.config.branding.title) > categoryX and #categories > 1 then + 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.config.branding.title, x=1, y=1, align="right", bg=theme.colors.headerBgColor, color = theme.colors.headerColor, width=categoryX-1 } + 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.config.branding.title, x=1, y=1, align=theme.formatting.headerAlign, bg=theme.colors.headerBgColor, color = theme.colors.headerColor, width=display.bgCanvas.width } + 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 table.insert(elements, header) local footerHeight = 0 - if props.config.settings.showFooter then + if props.configState.config.settings.showFooter then local footerMessage - if props.shopState.selectedCurrency.name or not props.config.lang.footerNoName then - footerMessage = props.config.lang.footer + if props.shopState.selectedCurrency.name or not props.configState.config.lang.footerNoName then + footerMessage = props.configState.config.lang.footer else - footerMessage = props.config.lang.footerNoName + 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%%") then + if footerMessage:find("%%addr%%") and props.shopState.selectedCurrency.host then footerMessage = footerMessage:gsub("%%addr%%", props.shopState.selectedCurrency.host) end if footerMessage:find("%%version%%") then @@ -118,7 +118,7 @@ local maxNameWidth = 0 props.shopState.numCategories = #categories local catName = categories[selectedCategory].name - local shopProducts = renderHelpers.getDisplayedProducts(categories[selectedCategory].products, props.config.settings) + local shopProducts = renderHelpers.getDisplayedProducts(categories[selectedCategory].products, props.configState.config.settings) local productsHeight = display.bgCanvas.height - 17 - footerHeight local heightPerProduct = math.floor(productsHeight / #shopProducts) local layout @@ -137,7 +137,7 @@ local currency = props.shopState.selectedCurrency local currencySymbol = renderHelpers.getCurrencySymbol(currency, layout) while maxAddrWidth == 0 or maxAddrWidth + maxQtyWidth + maxPriceWidth + maxNameWidth > display.bgCanvas.width - 3 do - if props.config.theme.formatting.layout == "auto" and (maxAddrWidth + maxQtyWidth + maxPriceWidth + maxNameWidth > display.bgCanvas.width - 3) then + 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 @@ -158,7 +158,7 @@ local productAddr = product.address .. "@" if props.shopState.selectedCurrency.name then if layout == "small" then - if props.config.settings.smallTextKristPayCompatability then + if props.configState.config.settings.smallTextKristPayCompatability then productAddr = product.address .. "@" .. props.shopState.selectedCurrency.name else productAddr = product.address .. "@ " @@ -186,7 +186,7 @@ maxNameWidth = math.max(maxNameWidth, renderHelpers.getWidth(product.name, layout)+1) end end - if props.config.theme.formatting.layout ~= "auto" or layout == "small" then + if props.configState.config.theme.formatting.layout ~= "auto" or layout == "small" then break end end @@ -211,7 +211,7 @@ local productAddr = product.address .. "@" if props.shopState.selectedCurrency.name then if layout == "small" then - if props.config.settings.smallTextKristPayCompatability then + if props.configState.config.settings.smallTextKristPayCompatability then productAddr = product.address .. "@" .. props.shopState.selectedCurrency.name else productAddr = product.address .. "@ " @@ -220,7 +220,7 @@ else productAddr = product.address end - local kristpayHelperText = props.shopState.selectedCurrency.host + local kristpayHelperText = props.shopState.selectedCurrency.host or "" if props.shopState.selectedCurrency.name then kristpayHelperText = product.address .. "@" .. props.shopState.selectedCurrency.name end @@ -386,9 +386,9 @@ end local currencyX = 3 - if #props.config.currencies > 1 then - for i = 1, #props.config.currencies do - local symbol = renderHelpers.getCurrencySymbol(props.config.currencies[i], "large") + 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 { @@ -401,10 +401,10 @@ color = theme.colors.currencyTextColor, width = symbolSize, onClick = function() - props.shopState.selectedCurrency = props.config.currencies[i] + props.shopState.selectedCurrency = props.configState.config.currencies[i] props.shopState.lastTouched = os.epoch("utc") - if props.config.settings.playSounds then - sound.playSound(props.speaker, props.config.sounds.button) + if props.configState.config.settings.playSounds then + sound.playSound(props.peripherals.speaker, props.configState.config.sounds.button) end end }) @@ -439,8 +439,8 @@ onClick = function() props.shopState.selectedCategory = i props.shopState.lastTouched = os.epoch("utc") - if props.config.settings.playSounds then - sound.playSound(props.speaker, props.config.sounds.button) + 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 diff --git a/Howlfile.lua b/Howlfile.lua index 9157a2c..a7f350c 100644 --- a/Howlfile.lua +++ b/Howlfile.lua @@ -8,7 +8,7 @@ } Tasks:require "main" { - include = {"components/*.lua", "core/*.lua", "fonts/*.lua", "Krypton/*.lua", "modules/*.lua", "res/*.lua", "util/*.lua", "DefaultLayout.lua", "radon.lua", "profile.lua"}, + include = {"components/*.lua", "core/*.lua", "fonts/*.lua", "Krypton/*.lua", "modules/*.lua", "res/*.lua", "util/*.lua", "DefaultLayout.lua", "radon.lua", "profile.lua", "configDefaults.lua"}, startup = "radon.lua", output = "build/radon.lua", } diff --git a/components/Alert.lua b/components/Alert.lua new file mode 100644 index 0000000..373e8ec --- /dev/null +++ b/components/Alert.lua @@ -0,0 +1,145 @@ +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) diff --git a/components/BasicButton.lua b/components/BasicButton.lua index 96a454f..fdc22d4 100644 --- a/components/BasicButton.lua +++ b/components/BasicButton.lua @@ -19,6 +19,6 @@ width = props.width, }, { -- canvas = canvas, - aabb = useBoundingBox((props.x*2)-1, (props.y*3)-2, (props.width or #props.text)*2, 3, props.onClick), + aabb = useBoundingBox((props.x*2)-1, (props.y*3)-2, (props.width or #props.text)*2, 3, props.onClick, props.onScroll), } end) diff --git a/components/BasicText.lua b/components/BasicText.lua index 8cac3e3..8053427 100644 --- a/components/BasicText.lua +++ b/components/BasicText.lua @@ -19,6 +19,9 @@ 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() diff --git a/components/ConfigEditor.lua b/components/ConfigEditor.lua new file mode 100644 index 0000000..652eda6 --- /dev/null +++ b/components/ConfigEditor.lua @@ -0,0 +1,662 @@ +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] + 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] + 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 + if subSchema.__type and subSchema.__type:sub(1,5) == "array" and subSchema.__entry then + fields = score.copyDeep(subConfig) + numKeys = numKeys + 1 + xOffset = 1 + 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 + for k, v in pairs(fields) do + 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 + 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 = " " .. k .. " ", + x = props.x + xOffset, + y = textY, + color = buttonTextColor, + bg = buttonColor, + width = math.min(#k+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(#k+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] + if inputStateValue == "%nil%" then + inputStateValue = nil + end + table.insert(elements, TextInput { + key = "config-value-"..k, + 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-" .. k .. "-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-" .. k .. "-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] }, + 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-"..k, + 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-"..k, + 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)] = 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 + 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) + setSaveModalOpen(false) + setArrayAdds({}) + setArrayRemoves({}) + setConfigDiffs({}) + setUpdates(updates + 1) + if props.onSave then + props.onSave(newConfig) + 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) diff --git a/components/Logs.lua b/components/Logs.lua new file mode 100644 index 0000000..c5782a5 --- /dev/null +++ b/components/Logs.lua @@ -0,0 +1,33 @@ +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) diff --git a/components/Modal.lua b/components/Modal.lua new file mode 100644 index 0000000..b106772 --- /dev/null +++ b/components/Modal.lua @@ -0,0 +1,29 @@ +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) diff --git a/components/Scrollbar.lua b/components/Scrollbar.lua new file mode 100644 index 0000000..95ce819 --- /dev/null +++ b/components/Scrollbar.lua @@ -0,0 +1,42 @@ +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) diff --git a/components/Select.lua b/components/Select.lua new file mode 100644 index 0000000..ec300aa --- /dev/null +++ b/components/Select.lua @@ -0,0 +1,192 @@ +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) diff --git a/components/TextInput.lua b/components/TextInput.lua new file mode 100644 index 0000000..e5d2ffe --- /dev/null +++ b/components/TextInput.lua @@ -0,0 +1,226 @@ +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:match("%D") then + return + elseif char == "-" then + if inputState.cursorPos ~= 1 or inputState.value:find("%-") then + return + end + 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, + }, + { + -- 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 + contents = contents:gsub("[^%d]", "") + 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) diff --git a/components/Toggle.lua b/components/Toggle.lua new file mode 100644 index 0000000..9eb5666 --- /dev/null +++ b/components/Toggle.lua @@ -0,0 +1,60 @@ +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) diff --git a/config.lua b/config.lua index 2c56bca..60035eb 100644 --- a/config.lua +++ b/config.lua @@ -1,6 +1,6 @@ return { branding = { - title = "Radon Shop" + title = nil }, settings = { hideUnavailableProducts = false, @@ -136,6 +136,81 @@ } } }, + 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", @@ -152,8 +227,8 @@ { id = "krist", -- if not krist or tenebra, must supply endpoint -- node = "https://krist.dev" - name = "radon.kst", - pkey = "", + name = nil, + pkey = nil, pkeyFormat = "raw", -- Currently must be 'raw' or 'kristwallet' -- You can get your raw pkey from kristweb or using https://pkey.its-em.ma/ value = 1.0 -- Default scaling on item prices, can be overridden on a per-item basis diff --git a/configDefaults.lua b/configDefaults.lua new file mode 100644 index 0000000..888c8ac --- /dev/null +++ b/configDefaults.lua @@ -0,0 +1,201 @@ +return { + branding = { + title = nil + }, + settings = { + hideUnavailableProducts = false, + pollFrequency = 30, + categoryCycleFrequency = -1, + activityTimeout = 60, + dropDirection = "forward", + smallTextKristPayCompatability = true, + playSounds = true, + showFooter = true, + }, + lang = { + footer = "/pay @%name% ", + footerNoName = "/pay %addr% ", + 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", + 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, + 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 + }, + }, + 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/" + } +} diff --git a/core/ConfigValidator.lua b/core/ConfigValidator.lua index ecadaaa..7c422e2 100644 --- a/core/ConfigValidator.lua +++ b/core/ConfigValidator.lua @@ -1,228 +1,69 @@ local r2l = require("modules.regex") - -local configSchema = { - branding = { - title = "string" - }, - settings = { - hideUnavailableProducts = "boolean", - pollFrequency = "number", - categoryCycleFrequency = "number", - activityTimeout = "number", - dropDirection = "enum<'forward' | 'up' | 'down' | 'north' | 'south' | 'east' | 'west'>: direction", - smallTextKristPayCompatability = "boolean", - playSounds = "boolean", - showFooter = "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", - 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", - 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" - } - }, - sounds = { - button = "sound", - purchase = "sound", - }, - currencies = { - __type = "array", - __min = 1, - __entry = { - id = "string", - node = "string?", - name = "string?", - 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", - }, - hooks = { - start = "function?", - prePurchase = "function?", - purchase = "function?", - failedPurchase = "function?", - programError = "function?", - blink = "function?", - }, - shopSync = { - enabled = "boolean?", - name = "string?", - description = "string?", - owner = "string?", - location = { - coordinates = { - __type = "array?", - __min = 3, - __max = 3, - __entry = "number" - }, - description = "string?", - dimension = "enum<'overworld' | 'nether' | 'end'>?: dimension" - } - }, - exchange = { - enabled = "boolean", - node = "string" - } -} - -local productsSchema = { - __type = "array", - __entry = { - modid = "string", - name = "string?", - address = "string", - order = "number?", - quantity = "number?", - category = "string?", - price = "number", - priceOverrides = { - __type = "array?", - __entry = { - currency = "string", - price = "number" - } - }, - predicate = "table?" - } -} +local schemas = require("core.schemas") local function typeCheck(entryType, typeName, value, path) if value then if entryType == "table" and type(value) ~= "table" then - error("Config value " .. subpath .. " must be a table") + return { path = subpath, error = "Must be a table" } end if entryType == "string" and type(value) ~= "string" then - error("Config value " .. subpath .. " must be a string") + return { path = subpath, error = "Must be a string" } end if entryType == "number" and type(value) ~= "number" then - error("Config value " .. subpath .. " must be a number") + return { path = subpath, error = "Must be a number" } end if entryType == "function" and type(value) ~= "function" then - error("Config value " .. subpath .. " must be a function") + return { path = subpath, error = "Must be a function" } end if entryType == "file" then if type(value) ~= "string" then - error("Config value " .. subpath .. " must be a file") + return { path = subpath, error = "Must be a file path" } end if not fs.exists(value) or fs.isDir(value) then - error("Config value " .. subpath .. " must refer to a file") + return { path = subpath, error = "File must exist" } end end if entryType == "color" then if type(value) ~= "number" then - error("Config value " .. subpath .. " must be a color") + 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 - error("Config value " .. subpath .. " must be a color") + return { path = subpath, error = "Must be a color" } end end if entryType == "modem" then if type(value) ~= "string" then - error("Config value " .. subpath .. " must be a modem name") + return { path = subpath, error = "Must be a modem name" } end if peripheral.getType(value) ~= "modem" then - error("Config value " .. subpath .. " must refer to a modem") + return { path = subpath, error = "Must refer to a modem" } end end if entryType == "speaker" then if type(value) ~= "string" then - error("Config value " .. subpath .. " must be a speaker name") + return { path = subpath, error = "Must be a speaker name" } end if peripheral.getType(value) ~= "speaker" then - error("Config value " .. subpath .. " must refer to a speaker") + return { path = subpath, error = "Must refer to a speaker" } end end if entryType == "chest" then if type(value) ~= "string" then - error("Config value " .. subpath .. " must be a networked chest") + return { path = subpath, error = "Must be a chest name" } end - if not turtle and (value == "left" or value == "right" or value == "front" or value == "back" or value == "top" or value == "bottom") then - error("Config value " .. subpath .. " must not be a relative position") + -- 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 - error("Config value " .. subpath .. " can only be self for turtles") + return { path = subpath, error = "Can only be self for turtles" } end if value ~= "self" then local chestMethods = peripheral.getMethods(value) if not chestMethods then - error("Config value " .. subpath .. " must refer to a valid peripheral") + return { path = subpath, error = "Must refer to a valid peripheral" } end local hasDropMethod = false for i = 1, #chestMethods do @@ -232,26 +73,26 @@ end end if not hasDropMethod then - error("Config value " .. subpath .. " must refer to a peripheral with an inventory") + return { path = subpath, error = "Must refer to an inventory" } end end end if entryType == "sound" then if type(value) ~= "table" then - error("Config value " .. subpath .. " must be a sound") + return { path = subpath, error = "Must be a sound" } end if not value.name or type(value.name) ~= "string" then - error("Config value " .. subpath .. " must have a name") + return { path = subpath, error = "Sound must have a name" } end if not value.volume or type(value.volume) ~= "number" then - error("Config value " .. subpath .. " must have a volume") + return { path = subpath, error = "Sound must have a volume" } end if not value.pitch or type(value.pitch) ~= "number" then - error("Config value " .. subpath .. " must have a pitch") + return { path = subpath, error = "Sound must have a pitch" } end end if entryType == "boolean" and type(value) ~= "boolean" then - error("Config value " .. subpath .. " must be a boolean") + 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) @@ -265,9 +106,9 @@ end if not found then if typeName then - error("Config value " .. subpath .. " must be entryType " .. typeName .. " matching " .. enum) + return { path = subpath, error = "Must be entryType " .. typeName .. " matching " .. enum } else - error("Config value " .. subpath .. " must be one of " .. enum) + return { path = subpath, error = "Must match " .. enum } end end end @@ -276,13 +117,14 @@ local regex = r2l.new(regexString) if not regex(value) then if typeName then - error("Config value " .. subpath .. " must be entryType " .. typeName .. " matching " .. regexString) + return { path = subpath, error = "Must be entryType " .. typeName .. " matching " .. regexString } else - error("Config value " .. subpath .. " must match " .. regexString) + return { path = subpath, error = "Must match " .. regexString } end end end end + return nil end local function validate(config, schema, path) @@ -295,32 +137,54 @@ return end if type(config) ~= "table" then - error("Config value " .. path .. " must be an array") + return { path = path, error = "Must be an array" } end if schema.__min and #config < schema.__min then - error("Config value " .. path .. " must have at least " .. schema.__min .. " entries") + return { path = path, error = "Must have at least " .. schema.__min .. " entries" } end if schema.__max and #config > schema.__max then - error("Config value " .. path .. " must have at most " .. schema.__max .. " entries") + 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" then - typeCheck(schema.__entry, schema.__entry, config[i], path .. "[" .. i .. "]") + local err = typeCheck(schema.__entry, schema.__entry, config[i], path .. "[" .. i .. "]") + if err then + table.insert(validationErrors, err) + end else - validate(config[i], schema.__entry, path .. "[" .. i .. "]") + 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 - validate(config[k], v, subpath) + 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 @@ -330,26 +194,51 @@ v = typeDef end if v:sub(-1) ~= "?" and config[k] == nil then - error("Missing required config value: " .. subpath) + table.insert(validationErrors, { + path = subpath, + error = "Missing required config value" + }) end if v:sub(-1) == "?" then v = v:sub(1, -2) end - typeCheck(v, typeName, config[k], subpath) + 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) - validate(config, configSchema, "config") + return validate(config, schemas.configSchema, "config") end local function validateProducts(products) - validate(products, productsSchema, "products") + return validate(products, schemas.productsSchema, "products") end return { + typeCheck = typeCheck, + validate = validate, validateConfig = validateConfig, - validateProducts = validateProducts + validateProducts = validateProducts, + validationArrayToMap = validationArrayToMap } \ No newline at end of file diff --git a/core/ShopState.lua b/core/ShopState.lua index d31f999..afb6e2f 100644 --- a/core/ShopState.lua +++ b/core/ShopState.lua @@ -13,20 +13,23 @@ local ShopState = {} local ShopState_mt = { __index = ShopState } -function ShopState.new(config, products, modem, shopSyncModem, speaker, version) +function ShopState.new(config, products, peripherals, version, logs) local self = setmetatable({}, ShopState_mt) self.running = false self.config = config + self.peripherals = peripherals self.products = products - self.modem = modem - self.shopSyncModem = shopSyncModem - self.speaker = speaker self.version = version - self.selectedCurrency = config.currencies[1] + 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.lastTouched = os.epoch("utc") return self @@ -120,10 +123,10 @@ if not turtle then error("Self output but not a turtle!") end - if not state.modem.getNameLocal() then + if not state.peripherals.modem.getNameLocal() then error("Modem is not connected! Try right clicking it") end - peripheral.call(productSource.inventory, "pushItems", state.modem.getNameLocal(), productSource.slot, productSource.amount, 1) + peripheral.call(productSource.inventory, "pushItems", state.peripherals.modem.getNameLocal(), productSource.slot, productSource.amount, 1) if state.config.settings.dropDirection == "forward" then turtle.drop(productSource.amount) elseif state.config.settings.dropDirection == "up" then @@ -143,7 +146,7 @@ refund(transactionCurrency, transaction.from, meta, refundAmount, state.config.lang.refundRemaining) end if state.config.settings.playSounds then - sound.playSound(state.speaker, state.config.sounds.purchase) + sound.playSound(state.peripherals.speaker, state.config.sounds.purchase) end if state.config.hooks and state.config.hooks.purchase then eventHook.execute(state.config.hooks.purchase, purchasedProduct, available, refundAmount, transaction, transactionCurrency) @@ -180,14 +183,14 @@ end end --- Anytime the shop state is resumed, animation should be finished instantly. (call animation finish hooks) ----@param state ShopState -local function runShop(state) - -- Shop is starting - state.running = true +local function setupKrypton(state) + state.selectedCurrency = state.config.currencies[1] state.currencies = {} - local kryptonListeners = {} + state.kryptonListeners = {} for _, currency in ipairs(state.config.currencies) do + if currency.name == "" then + currency.name = nil + end local node = currency.node if not node and currency.id == "krist" then node = "https://krist.dev/" @@ -221,8 +224,21 @@ error("Name " .. currency.name .. " is not owned by " .. currency.host .. "!") end end - table.insert(kryptonListeners, function() kryptonWs:listen() end) + table.insert(state.kryptonListeners, function() kryptonWs:listen() end) + state.kryptonReady = true end +end + +-- Anytime the shop state is resumed, animation should be finished instantly. (call animation finish hooks) +---@param state ShopState +local function runShop(state) + -- Shop is starting + -- Wait for config ready + while not state.config.ready do sleep(0.5) end + state.running = true + state.currencies = {} + state.kryptonListeners = {} + setupKrypton(state) parallel.waitForAny(function() while true do local event, transactionEvent = os.pullEvent("transaction") @@ -302,7 +318,7 @@ end, function() while state.running do sleep(shopSyncFrequency) - if state.config.shopSync and state.config.shopSync.enabled and state.shopSyncModem then + if state.config.shopSync and state.config.shopSync.enabled and state.peripherals.shopSyncModem then local items = {} for i = 1, #state.products do local product = state.products[i] @@ -343,7 +359,7 @@ } }) end - state.shopSyncModem.transmit(shopSyncChannel, os.getComputerID(), { + state.peripherals.shopSyncModem.transmit(shopSyncChannel, os.getComputerID(), { type = "ShopSync", info = { name = state.config.shopSync.name, @@ -361,7 +377,43 @@ }) end end - end, unpack(kryptonListeners)) + end, function() + while state.running do + if state.changedCurrencies and state.oldConfig then + state.changedCurrencies = false + state.kryptonReady = false + for i = 1, #state.oldConfig.currencies do + local currency = state.oldConfig.currencies[i] + if (currency.krypton and currency.krypton.ws) then + currency.krypton.ws:disconnect() + currency.krypton = nil + end + end + for i = 1, #state.currencies do + local currency = state.currencies[i] + if (currency.krypton and currency.krypton.ws) then + currency.krypton.ws:disconnect() + currency.krypton = nil + end + end + setupKrypton(state) + state.kryptonReady = true + state.oldConfig = nil + end + sleep(0.5) + end + end, function() + while state.running do + if state.kryptonReady then + parallel.waitForAny(function() + while state.kryptonReady do + sleep(0.5) + end + end, unpack(state.kryptonListeners)) + end + sleep(0.5) + end + end) end return { diff --git a/core/schemas.lua b/core/schemas.lua new file mode 100644 index 0000000..f85deb9 --- /dev/null +++ b/core/schemas.lua @@ -0,0 +1,246 @@ + +local configSchema = { + branding = { + title = "string" + }, + settings = { + hideUnavailableProducts = "boolean", + pollFrequency = "number", + categoryCycleFrequency = "number", + activityTimeout = "number", + dropDirection = "enum<'forward' | 'up' | 'down' | 'north' | 'south' | 'east' | 'west'>: direction", + smallTextKristPayCompatability = "boolean", + playSounds = "boolean", + showFooter = "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", + 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", + 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", + __min = 1, + __entry = { + id = "string", + node = "string?", + name = "string?", + 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", + }, + hooks = { + start = "function?", + prePurchase = "function?", + purchase = "function?", + failedPurchase = "function?", + programError = "function?", + blink = "function?", + }, + shopSync = { + enabled = "boolean?", + name = "string?", + description = "string?", + owner = "string?", + location = { + coordinates = { + __type = "array?", + __min = 3, + __max = 3, + __entry = "number" + }, + description = "string?", + dimension = "enum<'overworld' | 'nether' | 'end'>?: dimension" + } + }, + exchange = { + enabled = "boolean", + node = "string" + } +} + +local productsSchema = { + __type = "array", + __entry = { + modid = "string", + name = "string?", + address = "string", + category = "string?", + price = "number", + priceOverrides = { + __type = "array?", + __entry = { + currency = "string", + price = "number" + } + }, + predicate = "table?" + } +} + +local soundSchema = { + name = "string", + volume = "number", + pitch = "number" +} + +return { + configSchema = configSchema, + productsSchema = productsSchema, + soundSchema = soundSchema +} \ No newline at end of file diff --git a/modules/canvas.lua b/modules/canvas.lua index 047310c..f929117 100644 --- a/modules/canvas.lua +++ b/modules/canvas.lua @@ -443,6 +443,20 @@ 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 } diff --git a/modules/display.lua b/modules/display.lua index eea7d1d..97444ab 100644 --- a/modules/display.lua +++ b/modules/display.lua @@ -33,26 +33,32 @@ local TeletextCanvas = canvases.TeletextCanvas local TextCanvas = canvases.TextCanvas - - if self.mon then + self.mon = props.monitor + if self.mon and self.mon ~= term then self.mon = peripheral.wrap(props.monitor) end - if not self.mon then + if not self.mon and self.mon ~= term then self.mon = peripheral.find("monitor") end if not self.mon then self.mon = term - else + elseif self.mon ~= term then self.mon.setTextScale(0.5) end -- Set Riko Palette - require("util.setPalette")(self.mon, props.theme.palette) + if props.theme and props.theme.palette then + require("util.setPalette")(self.mon, props.theme.palette) + end - self.ccCanvas = TeletextCanvas(props.theme.colors.bgColor, self.mon.getSize()) + 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(props.theme.colors.headerBgColor, self.mon.getSize()) + self.textCanvas = TextCanvas(bgColor, self.mon.getSize()) self.bgCanvas = self.ccCanvas.pixelCanvas:newFromSize() -- for y = 1, self.bgCanvas.height do diff --git a/modules/hooks/aabb.lua b/modules/hooks/aabb.lua index d7a4133..1867f63 100644 --- a/modules/hooks/aabb.lua +++ b/modules/hooks/aabb.lua @@ -24,9 +24,9 @@ local Solyd = require("modules.solyd") -local function useBoundingBox(x, y, w, h, onClick) +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 } + return { x = x, y = y, w = w, h = h, onClick = onClick, onScroll = onScroll } end).value box.x = x @@ -34,6 +34,7 @@ box.w = w box.h = h box.onClick = onClick + box.onScroll = onScroll return box end diff --git a/modules/hooks/init.lua b/modules/hooks/init.lua index 4de01e2..8c32938 100644 --- a/modules/hooks/init.lua +++ b/modules/hooks/init.lua @@ -27,6 +27,9 @@ 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, } diff --git a/modules/hooks/input.lua b/modules/hooks/input.lua new file mode 100644 index 0000000..71e8d66 --- /dev/null +++ b/modules/hooks/input.lua @@ -0,0 +1,81 @@ +--[[ +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, +} diff --git a/modules/hooks/modals.lua b/modules/hooks/modals.lua new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/modules/hooks/modals.lua diff --git a/radon.lua b/radon.lua index 05c4029..64b45b0 100644 --- a/radon.lua +++ b/radon.lua @@ -1,7 +1,26 @@ +local configHelpers = require "util.configHelpers" +local schemas = require "core.schemas" 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 -local version = "1.2.2" +local version = "1.3.0" --- Imports local _ = require("util.score") @@ -16,7 +35,11 @@ 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") @@ -32,93 +55,294 @@ local loadRIF = require("modules.rif") +local configDefaults = require("configDefaults") local config = require("config") local products = require("products") --- End Imports -ConfigValidator.validateConfig(config) -ConfigValidator.validateProducts(products) +configHelpers.loadDefaults(config, configDefaults) +local configErrors = ConfigValidator.validateConfig(config) +local productsErrors = ConfigValidator.validateProducts(products) -local modem -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 +if (configErrors and #configErrors > 0) or (productsErrors and #productsErrors > 0) then + config.ready = false 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 +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, +} + local Main = Solyd.wrapComponent("Main", function(props) local canvas = useCanvas(display) - local theme = props.config.theme - local flatCanvas = {} - if theme.formatting.layout ~= "custom" or not theme.formatting.layoutFile then - flatCanvas = defaultLayout(canvas, display, props, theme, version) - else - if theme.formatting.layoutFile ~= layoutFile then - layoutFile = theme.formatting.layoutFile - local f = fs.open(layoutFile, "r") - if not f then - error("Could not open layout file: " .. layoutFile) + 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 - 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) + 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 - layoutRenderer = loadedString() - if theme.layouts[layoutFile] and theme.layouts[layoutFile].palette then - require("util.setPalette")(display.mon, theme.layouts[layoutFile].palette) + 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 - if theme.layouts[layoutFile] and theme.layouts[layoutFile].colors and theme.layouts[layoutFile].colors.bgColor then - display.ccCanvas.clear = theme.layouts[layoutFile].colors.bgColor + flatCanvas = layoutRenderer(canvas, display, props, theme, version) + if addBg then + print("Add", theme.layouts[layoutFile].colors.bgColor) + 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 - display.ccCanvas:outputFlush(display.mon) end - flatCanvas = layoutRenderer(canvas, display, props, theme, version) + 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.config or {}, + 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 Terminal = Solyd.wrapComponent("Terminal", function(props) + local canvas = useCanvas(terminal) + local theme = props.configState.config.terminalTheme + + local flatCanvas = {} + local versionString = "Radon " .. 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, + schema = schemas.configSchema, + errors = props.terminalState.configErrors, + errorPrefix = "config", + terminalState = props.terminalState, + theme = theme.colors.configEditor, + onSave = function(newConfig) + props.shopState.oldConfig = props.configState.config + props.shopState.config = newConfig + props.configState.config = newConfig + configErrors = ConfigValidator.validateConfig(newConfig) + if (not configErrors or #configErrors == 0) and (not productsErrors or #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 f = fs.open("config.lua", "w") + f.write("return " .. textutils.serialize(newConfig)) + f.close() + print("Configs updated!") + 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, + schema = schemas.productsSchema, + errors = props.terminalState.productsErrors, + errorPrefix = "products", + terminalState = props.terminalState, + theme = theme.colors.productsEditor, + onSave = function(newConfig) + props.shopState.products = newConfig + props.configState.products = newConfig + productsErrors = ConfigValidator.validateProducts(products) + if (not configErrors or #configErrors == 0) and (not productsErrors or #productsErrors == 0) then + props.configState.config.ready = true + end + local f = fs.open("products.lua", "w") + f.write("return " .. textutils.serialize(newConfig)) + f.close() + print("Products updated!") + 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) @@ -126,16 +350,20 @@ local t = 0 local tree = nil +local terminalTree = nil local lastClock = os.epoch("utc") -local lastCanvasStack = {} -local lastCanvasHash = {} -local function diffCanvasStack(newStack) +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, #lastCanvasStack do - removed[lastCanvasStack[i][1]] = lastCanvasStack[i] + 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 @@ -152,35 +380,35 @@ -- 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 - display.bgCanvas:dirtyRect(canvas[2], canvas[3], canvas[1].width*2, canvas[1].height*3) + diffDisplay.bgCanvas:dirtyRect(canvas[2], canvas[3], canvas[1].width*2, canvas[1].height*3) else - display.bgCanvas:dirtyRect(canvas[2], canvas[3], canvas[1].width, canvas[1].height) + 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 = lastCanvasHash[newCanvas[1]] + 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 - display.bgCanvas:dirtyRect(oldCanvas[2], oldCanvas[3], oldCanvas[1].width*2, oldCanvas[1].height*3) - display.bgCanvas:dirtyRect(newCanvas[2], newCanvas[3], newCanvas[1].width*2, newCanvas[1].height*3) + 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 - display.bgCanvas:dirtyRect(oldCanvas[2], oldCanvas[3], oldCanvas[1].width, oldCanvas[1].height) - display.bgCanvas:dirtyRect(newCanvas[2], newCanvas[3], newCanvas[1].width, newCanvas[1].height) + 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 - lastCanvasStack = newStack - lastCanvasHash = newCanvasHash + lastCanvas.stack = newStack + lastCanvas.hash = newCanvasHash end -local shopState = Core.ShopState.new(config, products, modem, shopSyncModem, speaker, version) +local shopState = Core.ShopState.new(config, products, peripherals, version, logs) local Profiler = require("profile") @@ -188,17 +416,17 @@ local deltaTimer = os.startTimer(0) local success, err = pcall(function() ShopRunner.launchShop(shopState, function() -- Profiler:activate() - print("Radon " .. version .. " has started") + print("Radon " .. version .. " started") if config.hooks and config.hooks.start then eventHook.execute(config.hooks.start, version, config, products) end while true do -- add t = t if we need animations - tree = Solyd.render(tree, Main { config = config, shopState = shopState, speaker = speaker}) + tree = Solyd.render(tree, Main { configState = configState, shopState = shopState, peripherals = peripherals}) local context = Solyd.getTopologicalContext(tree, { "canvas", "aabb" }) - diffCanvasStack(context.canvas) + diffCanvasStack(display, context.canvas, lastCanvases[1]) local t1 = os.epoch("utc") local cstack = { {display.bgCanvas, 1, 1}, unpack(context.canvas) } @@ -206,28 +434,101 @@ 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 e = { os.pullEvent() } 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) + -- local clock = os.epoch("utc") + -- local dt = (clock - lastClock)/1000 + -- t = t + dt + -- lastClock = clock + -- deltaTimer = os.startTimer(0) - hooks.tickAnimations(dt) + -- hooks.tickAnimations(dt) 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 == "key" then - if e[2] == keys.q then - break + 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 + 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 == "key" then + -- if e[2] == keys.q then + -- break + -- end elseif name == "terminate" then break end @@ -236,6 +537,10 @@ 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 diff --git a/util/configHelpers.lua b/util/configHelpers.lua new file mode 100644 index 0000000..b61b833 --- /dev/null +++ b/util/configHelpers.lua @@ -0,0 +1,198 @@ +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, _ in pairs(arrayAdds) 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 + 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, +} \ No newline at end of file