local Chest = require("storage.chest") local ItemGroup = require("storage.itemgroup") local Storage = require("storage") local Text = require("gfx.text") local List = require("gfx.list") local Event = require("gfx.event") local Padding = require("gfx.padding") local Container = require("gfx.container") local Element = require("gfx.element") local Progress = require("gfx.progress") local Children = require("gfx.prop.children") local Orientation = require("gfx.prop.orientation") local CACHE_FILE = "/.storage.cache" LOCK_FILE = "/.storage.lock" local function lock() if fs.exists(LOCK_FILE) then return false end -- Create lock file return fs.open(LOCK_FILE, "w+").close() end local function unlock() if not fs.exists(LOCK_FILE) then return false end -- Delete lock file local result, reason = pcall(fs.delete, LOCK_FILE) if not result then print("ERROR: Lock file could not be deleted: " .. reason) end return result end local function isLocked() return fs.exists(LOCK_FILE) end local function saveState(fname, ctrl) local ser = ctrl:toSerializable() local file = fs.open(fname, "w+") file.write(textutils.serialize(ser)) file.close() end local function loadState(fname, node) if not isLocked() then print("Not locked! Loading cache...") local file = fs.open(fname, "r") if file ~= nil then local ser = textutils.unserialize(file.readAll()) file.close() return Storage:fromSerializable(ser) end end print("Controller must scan chests...") local nodeName = peripheral.getName(node) local storageChests = {peripheral.find("inventory")} for i,v in ipairs(storageChests) do if peripheral.getName(v) == nodeName then table.remove(storageChests, i) break end end local controller = Storage:fromPeripherals(Storage.assumeHomogeneous(storageChests)) saveState(fname, controller) unlock() return controller end local function itemList(groups, wBudget, hBudget, savedState, onClick, setPage, runImport) local state = savedState or {} state.tab = state.tab or 1 local pages = math.ceil(#groups / (hBudget - 1)) -- Tab controls local btnPrev = Text:new{ text = " < ", bgColor = state.tab == 1 and colors.black or colors.gray, fgColor = state.tab == 1 and colors.red or colors.white, onClick = function(e, x, y, s) if state.tab > 1 then state.tab = state.tab - 1 setPage(nil) end return true end } local btnImport = Text:new{ text = "IMPORT", bgColor = colors.gray, fgColor = colors.white, onClick = function(e, x, y, s) runImport() return true end } local btnNext = Text:new{ text = " > ", bgColor = state.tab == pages and colors.black or colors.gray, fgColor = state.tab == pages and colors.red or colors.white, onClick = function(e, x, y, s) if state.tab < pages then state.tab = state.tab + 1 setPage(nil) end return true end } local padImport = Padding:new{ left = math.floor((wBudget - btnPrev:getWidth() - btnNext:getWidth() - btnImport:getWidth())/2), element = btnImport } local padNext = Padding:new{ left = wBudget - btnPrev:getWidth() - padImport:getWidth() - btnNext:getWidth(), element = btnNext } local tabLine = List:new{ [Children:getId()] = { btnPrev, padImport, padNext }, [Orientation:getId()] = Orientation.HORIZONTAL } local bgColors = { colors.gray, colors.black } local entryBudget = hBudget - tabLine:getHeight() local entryStart = 1 + (state.tab - 1) * (entryBudget) local entryEnd = math.min(state.tab * entryBudget, #groups) local entries = {} for i=entryStart,entryEnd do local group = groups[i] local text = group:getSimpleName() local count = tostring(group:getItemCount()) -- Fit text inside of width budget local countLen = #count if countLen + 2 > wBudget then error("Width budget is too small") end local textBudget = wBudget - 2 - countLen if #text > textBudget then -- Truncate to available budget text = text:sub(1,textBudget) end local nbtColor = group:getNBT() and colors.cyan or nil local textLabel = Text:new{ text = text, bgColor = bgColors[i % 2], fgColor = nbtColor } local countLabel = Text:new{ text = count, bgColor = bgColors[i % 2] } local paddedText = Padding:new{ left = 0, right = wBudget - #count - #text, top = 0, bottom = 0, bgColor = bgColors[i % 2], element = textLabel } local list = List:new{ [Children:getId()] = { paddedText, countLabel }, [Orientation:getId()] = Orientation.HORIZONTAL, onClick = onClick and function(element, x, y, source) return onClick(element, x, y, source, group) end or nil } table.insert(entries, list) end local tabLinePad = Padding:new{ top = hBudget - (entryEnd - entryStart) - 1 - tabLine:getHeight(), element = tabLine } table.insert(entries, tabLinePad) return List:new{ [Children:getId()] = entries, [Orientation:getId()] = Orientation.VERTICAL }, state end local function updatePageRender(state, pages) if state.nextPage ~= nil then state.changingPage = state.nextPage ~= state.currentPage state.currentPage = state.nextPage state.nextPage = nil state.monitor.clear() state._pageRender = pages[state.currentPage](state) os.queueEvent("dummy_event") else state.changingPage = false end end local function renderDefault(state, rootElement) rootElement:setParent(state.monitor) return function() local event = {os.pullEvent()} local handled = rootElement:handleEvent(event) if not handled and event[1] == "monitor_resize" then local width, height = state.monitor.getSize() if state.width ~= width or state.height ~= height then state.width = width state.height = height -- Trigger re-render rootElement:setDirty(true) end end if not handled and not Event.isClickEvent(event) then os.queueEvent(table.unpack(event)) end rootElement:draw() end end local function NOOP() end local function getCenterPad(contentWidth, widthBudget) if widthBudget < contentWidth then return 0, 0 end local pad = (widthBudget - contentWidth) / 2 return math.floor(pad), math.ceil(pad) end local function fitText(text, widthBudget) if #text <= widthBudget then return text end return text:sub(1, #widthBudget) end local PAGES = { MAIN = function(state) local pageState = state:currentPageState({}) if pageState.stacks == nil then pageState.stacks = ItemGroup.collectStacks(state.controller:find(function(stack) return not stack:isEmpty() end)) table.sort(pageState.stacks, function(a, b) return a:getItemCount() > b:getItemCount() end) end local listResult, listState = itemList( pageState.stacks, state.width, state.height, pageState.listState, function(element, x, y, source, group) state:setPage("GROUP_DETAIL", group) return true end, function(page) if page == nil then state:reloadPage() else state:setPage(page) end end, function() print("Importing...") state.node:rescan() -- Safely handle transfers, priming computer for a full reset/rescan in case server stops mid-transaction state:itemTransaction(function() for _,nodeStack in ipairs(state.node:find(function(s) return not s:isEmpty() end)) do if not state.controller:insertStack(nodeStack) then print("Couldn't find a free slot for: "..nodeStack:getSimpleName()) end end end) -- Force reload stacks state:currentPageState({}).stacks = nil state:reloadPage() end ) pageState.listState = listState return renderDefault(state, listResult) end, GROUP_DETAIL = function(state, newPage) local paddingSide = 0 local paddingTop = 0 local group = state:getExtra() if group == nil then print("No group passed to GROUP_DETAIL") state:setPage("MAIN") return NOOP end local itemName = fitText(group:getSimpleName(), state.width - (paddingSide * 2)) local itemLeftPad, itemRightPad = getCenterPad(#itemName, state.width - (paddingSide * 2)) local paddedTitle = Padding:new{ top = 1, left = itemLeftPad, right = itemRightPad, bottom = 1, element = Text:new{ text = itemName, bgColor = colors.gray }, bgColor = colors.gray } local simpleDetailsList = List:new{ [Orientation:getId()] = Orientation.HORIZONTAL, [Children:getId()] = { Padding:new{ top = 0, left = 1, right = 1, bottom = 0, bgColor = colors.red, element = List:new{ bgColor = colors.red, [Orientation:getId()] = Orientation.VERTICAL, [Children:getId()] = { Text:new{ text = "Count", bgColor = colors.red }, Text:new{ text = tostring(group:getItemCount()), bgColor = colors.red } } } }, Padding:new{ bgColor = colors.blue, top = 0, left = 1, right = 1, bottom = 0, element = List:new{ bgColor = colors.blue, [Orientation:getId()] = Orientation.VERTICAL, [Children:getId()] = { Text:new{ text = "Slots", bgColor = colors.blue }, Text:new{ text = tostring(group:getStackCount()), bgColor = colors.blue } } } } } } local damage = group:getDamage() local maxDamage = group:getMaxDamage() local damageBar = nil if damage ~= nil and maxDamage ~= nil then damageBar = Padding:new{ left = 1, right = 1, element = Progress:new{ progress = (damage == 0 and maxDamage or damage / maxDamage), width = state.width - 2, height = 1, fgColor = colors.green } } end local infoElements = List:new{ [Children:getId()] = { paddedTitle, Padding:new{ top = 0, bottom = 1, left = 0, right = 0, element = simpleDetailsList }, damageBar }, [Orientation:getId()] = Orientation.VERTICAL, width = state.width } local stuffContainer = Container:new{ [Children:getId()] = { Padding:new{ top = paddingTop, left = paddingSide, right = paddingSide, bottom = 0, element = infoElements, }, Padding:new{ x = 1, y = state.height - 2, top = 1, left = 1, right = 1, bottom = 1, bgColor = colors.gray, element = Text:new{ text = "Request", bgColor = colors.gray }, onClick = function(e, x, y, s) state:setPage("REQUEST", group) return true end } }, width = state.width, height = state.height, parent = state.monitor, onClick = function(e, x, y, s) state:setPage("MAIN") return true end } return renderDefault(state, stuffContainer) end, REQUEST = function(state, newPage) local group = state:getExtra() if group == nil then print("No group passed to REQUEST") state:setPage("MAIN") return NOOP end local pageState = state:currentPageState( { request = 0, group = group }, function(currentPageState) -- True = reset state return currentPageState ~= group end ) local itemName = fitText(group:getSimpleName(), state.width) local itemLeftPad, itemRightPad = getCenterPad(#itemName, state.width) local paddedTitle = Padding:new{ top = 1, left = itemLeftPad, right = itemRightPad, bottom = 1, element = Text:new{ text = itemName, bgColor = colors.gray }, bgColor = colors.gray } local function makeRequestButton(increment) local text = increment > 0 and ("+"..tostring(increment)) or tostring(increment) return Text:new{ id = text, text = text } end local PADDING_RQC_H = 1 local paddedRequestCount = Padding:new{ top = 3, left = PADDING_RQC_H, right = PADDING_RQC_H, bottom = 0, element = List:new{ [Orientation:getId()] = Orientation.VERTICAL, [Children:getId()] = { List:new{ [Orientation:getId()] = Orientation.HORIZONTAL, [Children:getId()] = { Text:new{ text = "Request" }, Padding:new{ top = 0, bottom = 0, left = math.floor((state.width - (PADDING_RQC_H * 2) - #("Request") - #("Available")) / 2), right = math.ceil((state.width - (PADDING_RQC_H * 2) - #("Request") - #("Available")) / 2), element = Text:new{ text = "/" } }, Text:new{ text = "Available" }, } }, List:new{ id = "list_request_count", [Orientation:getId()] = Orientation.HORIZONTAL, [Children:getId()] = { Text:new{ id = "data_request", text = tostring(pageState.request) }, Padding:new{ id = "data_divider", top = 0, bottom = 0, element = Text:new{ text = "/" } }, Text:new{ id = "data_available", text = tostring(group:getItemCount()) }, } }, Progress:new{ id = "request_capacity", [Orientation:getId()] = Orientation.HORIZONTAL, width = state.width - (PADDING_RQC_H * 2), height = 1, progress = pageState.request / group:getItemCount() }, Element:new{ height = 1, width = 0 }, Padding:new{ top = 0, bottom = 0, left = math.floor((state.width - 12)/2), right = math.ceil((state.width - 12)/2), element = List:new{ [Orientation:getId()] = Orientation.HORIZONTAL, [Children:getId()] = { makeRequestButton(-5), Element:new{ width = 1 }, makeRequestButton(-1), Element:new{ width = 2 }, makeRequestButton(1), Element:new{ width = 1 }, makeRequestButton(5), } } } } } } local function paddedButton(label, width, height, parentHeight, parentWidth, onClick, bgColor, x, y) local labelElement = Text:new{ text = label, bgColor = bgColor } return Padding:new{ x = (parentWidth + (x or 1)) % parentWidth, y = (parentHeight + (y or 1)) % parentHeight, top = math.ceil((height - labelElement:getHeight()) / 2), bottom = math.floor((height - labelElement:getHeight()) / 2), left = math.ceil((width - labelElement:getWidth()) / 2), right = math.floor((width - labelElement:getWidth()) / 2), element = labelElement, bgColor = bgColor, onClick = onClick } end local stuffContainer = List:new{ [Orientation:getId()] = Orientation.VERTICAL, [Children:getId()] = { paddedTitle, paddedRequestCount }, onClick = function(e, x, y, s) state:setPage("GROUP_DETAIL", group) return true end } -- Anchored to (1,1) local windowWrapper = Container:new{ [Children:getId()] = { stuffContainer, paddedButton( "Fetch", state.width / 2, 3, state.height, state.width, function() state:itemTransaction(function() group:transferTo(state.node) end) state:setPage("Main") return true end, colors.gray, 1, -2 ), paddedButton( "Back", state.width / 2, 3, state.height, state.width, function() state:setPage("GROUP_DETAIL", group) return true end, colors.gray, math.ceil(state.width / 2), -2 ) } } -- Set up event management local function updateDisplayState() local dataRequestText = paddedRequestCount:findById("data_request") local dataDividerPad = paddedRequestCount:findById("data_divider") local dataAvailableText = paddedRequestCount:findById("data_available") local requestCapProgress = paddedRequestCount:findById("request_capacity") local requestCountList = paddedRequestCount:findById("list_request_count") local requestText = tostring(pageState.request) local availableText = tostring(group:getItemCount()) dataRequestText:setText(requestText) dataDividerPad:setPadding{ left = math.ceil((state.width - 1 - (PADDING_RQC_H * 2) - #requestText - #availableText) / 2), right = math.floor((state.width - 1 - (PADDING_RQC_H * 2) - #requestText - #availableText) / 2) } dataAvailableText:setText(availableText) requestCapProgress:setProgress(pageState.request / group:getItemCount()) requestCountList:adjustPositions() stuffContainer:setDirty(true) end local function bindRequestButton(increment) local id = increment > 0 and ("+"..tostring(increment)) or tostring(increment) paddedRequestCount:findById(id):setOnClick(function(e, x, y, s) local newValue = math.max(0, math.min(pageState.request + increment, group:getItemCount())) if pageState.request ~= newValue then pageState.request = newValue updateDisplayState() end return true end) end bindRequestButton(-5) bindRequestButton(-1) bindRequestButton(1) bindRequestButton(5) updateDisplayState() return renderDefault(state, windowWrapper) end } local accessNode = Chest:fromPeripheral("minecraft:trapped_chest", true) ---@diagnostic disable-next-line: need-check-nil local controller = loadState(CACHE_FILE, accessNode:getInventory()) local monitor = peripheral.find("monitor") local width, height = monitor.getSize() local CONTROLLER_STATE = { controller = controller, node = accessNode, width = width, height = height, monitor = monitor, nextPage = "MAIN", exit = false, pageState = {} } function CONTROLLER_STATE:itemTransaction(transact) lock() transact() saveState(CACHE_FILE, self.controller) unlock() end function CONTROLLER_STATE:currentPageState(default, override) self.pageState[self.currentPage] = ((not override or (type(override) ~= "function" or not override(self.pageState[self.currentPage]))) and self.pageState[self.currentPage]) or default return self.pageState[self.currentPage] end function CONTROLLER_STATE:setPage(page, extra) self.nextPage = page self.extra = extra end function CONTROLLER_STATE:getExtra() return self.extra end function CONTROLLER_STATE:reloadPage() self.nextPage = self.currentPage end while not CONTROLLER_STATE.exit do updatePageRender(CONTROLLER_STATE, PAGES) CONTROLLER_STATE._pageRender() end