local mon = peripheral.find("monitor") assert(mon, "No monitor found") local monName = peripheral.getName(mon) mon.setTextScale(0.5) local SCREEN_WIDTH, SCREEN_HEIGHT = mon.getSize() local oldTerm = term.redirect(mon) local function ensureDependency(path, url) if fs.exists(path) then return end assert(shell and type(shell.run) == "function", "Shell API is not available") assert(shell.run("wget", url, path) and fs.exists(path), "Could not download dependency: " .. path) end ensureDependency("Pine3D.lua", "https://raw.githubusercontent.com/Xella37/Pine3D/main/Pine3D.lua") ensureDependency("betterblittle.lua", "https://raw.githubusercontent.com/Xella37/Pine3D/main/betterblittle.lua") ensureDependency("morefonts-pe.lua", "https://raw.githubusercontent.com/MichielP1807/more-fonts/main/pine3d/morefonts-pe.lua") local Pine3D = require("Pine3D") local mf = require("morefonts-pe") local frame = Pine3D.newFrame() frame:setBackgroundColor(colors.black) local function findStorageBridge() return peripheral.find("meBridge") or peripheral.find("me_bridge") or peripheral.find("rsBridge") or peripheral.find("rs_bridge") end local meSystem = findStorageBridge() local function ensureStorageBridge() if not meSystem then meSystem = findStorageBridge() end return meSystem end local chainItemsByBaseId local baseItemIds local itemById local defaultBaseId local whitelistLookup local fallbackIcon = false local backButton = false local scrollUpButton = false local scrollDownButton = false local overviewCachedCounts = {} local overviewCachedSortedItemIds = nil local overviewScrollCurrentRow = 0 local overviewScrollTargetRow = 0 local drawOverview local function ensureWhitelistLoaded() if whitelistLookup ~= nil then return end if not fs.exists("whitelist.json") then whitelistLookup = false return end local file = assert(fs.open("whitelist.json", "r")) local data = textutils.unserializeJSON(file.readAll()) file.close() assert(type(data) == "table", "Invalid whitelist.json data") if #data == 0 then whitelistLookup = false return end whitelistLookup = {} for i = 1, #data do local baseId = data[i] assert(type(baseId) == "string", "Invalid whitelist.json entry") whitelistLookup[baseId] = true end end local function ensureChainsLoaded() if not chainItemsByBaseId then ensureWhitelistLoaded() local file = assert(fs.open("atc_chains.json", "r")) local data = textutils.unserializeJSON(file.readAll()) file.close() local chains = data if type(data) == "table" and type(data.chains) == "table" then chains = data.chains end assert(type(chains) == "table", "Invalid JSON data") chainItemsByBaseId = {} baseItemIds = {} itemById = {} for _, chain in ipairs(chains) do local baseId = chain.base_id or chain[1] if not whitelistLookup or whitelistLookup[baseId] then if not defaultBaseId then defaultBaseId = baseId end baseItemIds[#baseItemIds + 1] = baseId local items = {} local itemCount = 0 if chain.base_id then for _, item in ipairs(chain.items or {}) do if item.stage == "item" then itemById[item.item_id] = { id = item.item_id, icon_nfp = item.icon_nfp_16x16, } else itemCount = itemCount + 1 items[itemCount] = { id = item.item_id, icon_nfp = item.icon_nfp_16x16, } itemById[item.item_id] = items[itemCount] end end else itemById[baseId] = { id = baseId, icon_nfp = chain[2], } for i = 1, #(chain[3] or {}) do local compactItem = chain[3][i] itemCount = itemCount + 1 items[itemCount] = { id = compactItem[1], icon_nfp = compactItem[2], } itemById[compactItem[1]] = items[itemCount] end end chainItemsByBaseId[baseId] = items end end end end local function getPageItems(base_id) ensureChainsLoaded() return chainItemsByBaseId[base_id or defaultBaseId] or {} end local function getBaseItemIds() ensureChainsLoaded() return baseItemIds end local function getItemById(item_id) ensureChainsLoaded() return itemById[item_id] end local function parseNfpImage(nfp) if type(nfp) ~= "string" or nfp == "" then return nil end local image = {} local y = 1 for line in (nfp .. "\n"):gmatch("(.-)\n") do local row = {} for x = 1, #line do local c = line:sub(x, x) if c ~= " " then local value = tonumber(c, 16) if value then row[x] = 2 ^ value end end end image[y] = row y = y + 1 end return image end local function parseIconData(iconData) if type(iconData) == "string" then return parseNfpImage(iconData) end if type(iconData) ~= "table" then return nil end local offsetX = tonumber(iconData[1]) or 0 local offsetY = tonumber(iconData[2]) or 0 local croppedImage = parseNfpImage(iconData[3]) if not croppedImage then return nil end if offsetX == 0 and offsetY == 0 then return croppedImage end local image = {} for y, row in pairs(croppedImage) do local shiftedRow = {} for x, value in pairs(row) do shiftedRow[x + offsetX] = value end image[y + offsetY] = shiftedRow end return image end local function getItemIcon(item) if not item then return nil end if item.icon == nil then item.icon = parseIconData(item.icon_nfp) or false item.icon_nfp = nil end return item.icon or nil end local function getMeItemCount(itemId) local bridge = ensureStorageBridge() if not bridge or not itemId then return 0 end if type(bridge.getItem) == "function" then local ok, item = pcall(bridge.getItem, { name = itemId }) if ok and item then return tonumber(item.amount or item.count or item.qty) or 0 end end return 0 end local function getMeItemCounts(itemIds) local counts = {} for i = 1, #itemIds do counts[itemIds[i]] = 0 end local bridge = ensureStorageBridge() if not bridge then return counts end local unresolved = {} local unresolvedCount = 0 for i = 1, #itemIds do local itemId = itemIds[i] local count = getMeItemCount(itemId) counts[itemId] = count if count == 0 then unresolved[itemId] = true unresolvedCount = unresolvedCount + 1 end end if unresolvedCount == 0 then return counts end local listItems = bridge.listItems or bridge.getItems or bridge.listAvailableItems if not listItems then return counts end local ok, items = pcall(listItems) if not ok or type(items) ~= "table" then return counts end for i = 1, #items do local item = items[i] local itemId = item and (item.name or item.id or item.item_id) if itemId and unresolved[itemId] then counts[itemId] = tonumber(item.amount or item.count or item.qty) or 0 unresolved[itemId] = nil unresolvedCount = unresolvedCount - 1 if unresolvedCount == 0 then break end end end return counts end local function makeZeroCounts(itemIds) local counts = {} for i = 1, #itemIds do counts[itemIds[i]] = 0 end return counts end local function createAsyncMeItemCountsJob(itemIds, batchSize) local counts = makeZeroCounts(itemIds) local index = 1 batchSize = math.max(1, math.floor(tonumber(batchSize) or 1)) return { step = function() local bridge = ensureStorageBridge() if not bridge then return true, counts end local lastIndex = math.min(#itemIds, index + batchSize - 1) for i = index, lastIndex do counts[itemIds[i]] = getMeItemCount(itemIds[i]) end index = lastIndex + 1 return index > #itemIds, counts end, } end local function getFallbackIcon() if fallbackIcon == false then fallbackIcon = parseNfpImage(table.concat({ "0000000000000000", "000000eeee000000", "00000eeeeee00000", "0000eee00eee0000", "00000e00000ee000", "0000000000eee000", "000000000eee0000", "00000000eee00000", "00000000ee000000", "0000000000000000", "00000000ee000000", "00000000ee000000", "0000000000000000", "0000000000000000", "0000000000000000", "0000000000000000", }, "\n")) end return fallbackIcon end local function getBackButton() if backButton == false then backButton = parseNfpImage(table.concat({ "e e", " e ", "e e", }, "\n")) end return backButton end local function getScrollUpButton() if scrollUpButton == false then scrollUpButton = parseNfpImage(table.concat({ " 00 ", "0000", "0 0", }, "\n")) end return scrollUpButton end local function getScrollDownButton() if scrollDownButton == false then scrollDownButton = parseNfpImage(table.concat({ "0 0", "0000", " 00 ", }, "\n")) end return scrollDownButton end local function imageSize(img) local w, h = 0, 0 for y, row in pairs(img) do h = math.max(h, y) for x, value in pairs(row) do if type(value) == "number" and value > 0 then w = math.max(w, x) end end end return w, h end local function formatCount(n) n = math.floor(tonumber(n) or 0) if n < 1000 then return tostring(n) elseif n < 100000 then local v = n / 1000 if v < 10 then return string.format("%.1fK", v):gsub("%.0K", "K") end return tostring(math.floor(v)) .. "K" elseif n < 100000000 then local v = n / 1000000 if v < 10 then return string.format("%.1fM", v):gsub("%.0M", "M") end return tostring(math.floor(v)) .. "M" elseif n < 100000000000 then local v = n / 1000000000 if v < 10 then return string.format("%.1fB", v):gsub("%.0B", "B") end return tostring(math.floor(v)) .. "B" else local v = n / 1000000000000 if v < 10 then return string.format("%.1fT", v):gsub("%.0T", "T") end return tostring(math.floor(v)) .. "T" end end local function drawNfpScaled(buffer, img, x, y, scaleX, scaleY) scaleX = scaleX or 2 scaleY = scaleY or 3 for imgY, row in pairs(img) do for imgX, col in pairs(row) do -- IMPORTANT: -- Only write real ComputerCraft colors into the Pine3D buffer. -- No strings, no nil values, no 0. if type(col) == "number" and col > 0 then local px = x + (imgX - 1) * scaleX local py = y + (imgY - 1) * scaleY for dy = 0, scaleY - 1 do for dx = 0, scaleX - 1 do local drawX = px + dx local drawY = py + dy if drawX >= 1 and drawX <= buffer.width and drawY >= 1 and drawY <= buffer.height then buffer:setPixel(drawX, drawY, col) end end end end end end end local function fillRect(buffer, x, y, width, height, color) local startX = math.max(1, x) local startY = math.max(1, y) local endX = math.min(buffer.width, x + width - 1) local endY = math.min(buffer.height, y + height - 1) if startX > endX or startY > endY then return end for py = startY, endY do for px = startX, endX do buffer:setPixel(px, py, color) end end end local function cellToPixelX(cellX) return (cellX - 1) * 2 + 1 end local function cellToPixelY(cellY) return (cellY - 1) * 3 + 1 end local function assertBufferValid(frame) local valid = { [colors.white] = true, [colors.orange] = true, [colors.magenta] = true, [colors.lightBlue] = true, [colors.yellow] = true, [colors.lime] = true, [colors.pink] = true, [colors.gray] = true, [colors.lightGray] = true, [colors.cyan] = true, [colors.purple] = true, [colors.blue] = true, [colors.brown] = true, [colors.green] = true, [colors.red] = true, [colors.black] = true, } for y = 1, frame.buffer.height do for x = 1, frame.buffer.width do local c = frame.buffer.colorValues[y][x] if not valid[c] then error("Invalid pixel in buffer at x=" .. x .. ", y=" .. y .. ": " .. tostring(c)) end end end end local function drawItem(img, cellX, cellY, count, offsetY) -- Convert normal monitor cells into Pine3D teletext pixels. local x = cellToPixelX(cellX) local y = cellToPixelY(cellY) + (offsetY or 0) local imgW, imgH = 0, 0 local scaleX = 1 local scaleY = 1 if img then imgW, imgH = imageSize(img) if y <= frame.buffer.height and y + imgH * scaleY - 1 >= 1 then drawNfpScaled(frame.buffer, img, x, y, scaleX, scaleY) end end local text = formatCount(count) local rightX = x + 16 -- x + math.max(imgW * scaleX - 1, 7) local bottomY = y + 16 -- y + math.max(imgH * scaleY - 1, 4) local options = { font = "fonts/QuinqueFive", textAlign = "right", anchorHor = "right", anchorVer = "bottom", condense = true, scale = 1, dx = 1, dy = 1, } if bottomY >= 1 and y <= frame.buffer.height then -- Shadow mf.writeOn(frame, text, colors.black, rightX + 1, bottomY + 1, options) -- White count mf.writeOn(frame, text, colors.white, rightX, bottomY, options) end end local function runScrollableGrid(options) local entries = options.getInitialEntries() local counts = options.getInitialCounts(entries) local visibleItemCount = 0 local totalRows = 0 local maxScrollRow = 0 local currentScrollRow = 0 local targetScrollRow = 0 local refreshTimer local animationTimer local refreshJob local refreshJobTimer local queuedRefresh = false local columns = 3 local rowsPerView = 3 local baseCellX = 4 local baseCellY = 2 local colStepCells = 12 local rowStepCells = 6 local rowStepPixels = rowStepCells * 3 local scrollbarX = SCREEN_WIDTH - 1 local scrollbarWidth = 2 local scrollbarButtonHeight = 3 local scrollbarTrackY = scrollbarButtonHeight + 1 local scrollbarTrackHeight = SCREEN_HEIGHT - scrollbarButtonHeight * 2 local scrollbarPixelX = cellToPixelX(scrollbarX) local scrollbarPixelWidth = scrollbarWidth * 2 local scrollbarTrackPixelY = cellToPixelY(scrollbarTrackY) local scrollbarTrackPixelHeight = math.max(0, scrollbarTrackHeight * 3) if options.getInitialScrollState then local initialCurrentRow, initialTargetRow = options.getInitialScrollState() currentScrollRow = tonumber(initialCurrentRow) or 0 targetScrollRow = tonumber(initialTargetRow) or currentScrollRow end local function snapScrollRow(row) return math.floor((tonumber(row) or 0) + 0.5) end local function persistScrollState() if options.setScrollState then options.setScrollState(snapScrollRow(targetScrollRow), snapScrollRow(targetScrollRow)) end end local function clampScrollRow(row) return math.max(0, math.min(maxScrollRow, row)) end local function getThumbMetrics() if scrollbarTrackPixelHeight <= 0 then return scrollbarTrackPixelY, 0 end if totalRows <= rowsPerView or maxScrollRow == 0 then return scrollbarTrackPixelY, scrollbarTrackPixelHeight end local thumbHeight = math.max(4, math.floor(scrollbarTrackPixelHeight * rowsPerView / totalRows + 0.5)) local thumbTravel = scrollbarTrackPixelHeight - thumbHeight local thumbY = scrollbarTrackPixelY + math.floor((currentScrollRow / maxScrollRow) * thumbTravel + 0.5) return thumbY, thumbHeight end local function scheduleAnimation() if not animationTimer and math.abs(targetScrollRow - currentScrollRow) > 0.001 then animationTimer = os.startTimer(0.05) end end local function scrollTo(row) local clampedRow = clampScrollRow(snapScrollRow(row)) if clampedRow ~= targetScrollRow then targetScrollRow = clampedRow persistScrollState() scheduleAnimation() end end local function startRefreshJob() if refreshJob then return false end refreshJob = options.createRefreshJob(entries, counts) if not refreshJob then return false end refreshJobTimer = os.startTimer(options.refreshStepSeconds or 0.05) return true end local function renderGrid() totalRows = math.ceil(#entries / columns) maxScrollRow = math.max(0, totalRows - rowsPerView) currentScrollRow = clampScrollRow(currentScrollRow) targetScrollRow = clampScrollRow(snapScrollRow(targetScrollRow)) persistScrollState() local firstVisibleRow = math.floor(currentScrollRow) local rowOffsetPixels = -math.floor((currentScrollRow - firstVisibleRow) * rowStepPixels + 0.5) local firstVisibleIndex = firstVisibleRow * columns + 1 local lastVisibleIndex = math.min(#entries, (firstVisibleRow + rowsPerView + 1) * columns) frame.buffer:clear() if options.drawChrome then options.drawChrome() end for i = firstVisibleIndex, lastVisibleIndex do local entry = entries[i] local relativeIndex = i - firstVisibleIndex local col = relativeIndex % columns local row = math.floor(relativeIndex / columns) drawItem( options.getIcon(entry), baseCellX + colStepCells * col, baseCellY + rowStepCells * row, counts[options.getId(entry)] or 0, rowOffsetPixels ) end visibleItemCount = math.max(0, math.min(#entries - firstVisibleRow * columns, rowsPerView * columns)) local upButtonY = cellToPixelY(1) local downButtonY = cellToPixelY(SCREEN_HEIGHT - scrollbarButtonHeight + 1) local upColor = targetScrollRow > 0 and colors.gray or colors.lightGray local downColor = targetScrollRow < maxScrollRow and colors.gray or colors.lightGray local thumbY, thumbHeight = getThumbMetrics() fillRect(frame.buffer, scrollbarPixelX, upButtonY, scrollbarPixelWidth, scrollbarButtonHeight * 3, upColor) fillRect(frame.buffer, scrollbarPixelX, downButtonY, scrollbarPixelWidth, scrollbarButtonHeight * 3, downColor) if scrollbarTrackPixelHeight > 0 then fillRect(frame.buffer, scrollbarPixelX, scrollbarTrackPixelY, scrollbarPixelWidth, scrollbarTrackPixelHeight, colors.gray) fillRect(frame.buffer, scrollbarPixelX, thumbY, scrollbarPixelWidth, thumbHeight, colors.white) end drawNfpScaled(frame.buffer, getScrollUpButton(), scrollbarPixelX, upButtonY + 2, 1, 1) drawNfpScaled(frame.buffer, getScrollDownButton(), scrollbarPixelX, downButtonY + 3, 1, 1) assertBufferValid(frame) frame:drawBuffer() end renderGrid() startRefreshJob() refreshTimer = os.startTimer(options.refreshSeconds or 30) while true do local event, p1, x, y = os.pullEvent() if event == "timer" and p1 == refreshTimer then if refreshJob then queuedRefresh = true else startRefreshJob() end refreshTimer = os.startTimer(options.refreshSeconds or 30) scheduleAnimation() elseif event == "timer" and p1 == refreshJobTimer then local isDone, newEntries, newCounts = refreshJob.step() if isDone then refreshJob = nil refreshJobTimer = nil if newEntries and newCounts then entries = newEntries counts = newCounts renderGrid() end if queuedRefresh then queuedRefresh = false startRefreshJob() end else refreshJobTimer = os.startTimer(options.refreshStepSeconds or 0.05) end elseif event == "timer" and p1 == animationTimer then local delta = targetScrollRow - currentScrollRow if math.abs(delta) <= 0.001 then currentScrollRow = targetScrollRow animationTimer = nil else local step = delta * 0.35 if step > 0 then step = math.max(0.08, math.min(step, 0.45)) else step = math.min(-0.08, math.max(step, -0.45)) end if math.abs(step) >= math.abs(delta) then currentScrollRow = targetScrollRow animationTimer = nil else currentScrollRow = currentScrollRow + step animationTimer = os.startTimer(0.05) end end persistScrollState() renderGrid() elseif event == "monitor_touch" and p1 == monName then if options.handleChromeTouch and options.handleChromeTouch(x, y) then return elseif x >= scrollbarX and x < scrollbarX + scrollbarWidth then if y <= scrollbarButtonHeight then scrollTo(targetScrollRow - 1) elseif y > SCREEN_HEIGHT - scrollbarButtonHeight then scrollTo(targetScrollRow + 1) elseif scrollbarTrackHeight > 0 then local touchPixelY = cellToPixelY(y) + 1 local thumbY, thumbHeight = getThumbMetrics() if (touchPixelY < thumbY or touchPixelY > thumbY + thumbHeight - 1) and maxScrollRow > 0 then local thumbTravel = scrollbarTrackPixelHeight - thumbHeight local targetPixelY = math.max( scrollbarTrackPixelY, math.min(scrollbarTrackPixelY + thumbTravel, touchPixelY - math.floor(thumbHeight / 2)) ) local progress = (targetPixelY - scrollbarTrackPixelY) / math.max(1, thumbTravel) scrollTo(progress * maxScrollRow) end end else local col = math.floor((x - baseCellX) / colStepCells) local firstVisibleRow = math.floor(currentScrollRow) local rowOffsetPixels = (currentScrollRow - firstVisibleRow) * rowStepPixels local touchPixelY = cellToPixelY(y) + 1 local row = math.floor((touchPixelY - cellToPixelY(baseCellY) + rowOffsetPixels) / rowStepPixels) if col >= 0 and col < columns and row >= 0 and row < rowsPerView + 1 then local index = (firstVisibleRow + row) * columns + col + 1 if index >= 1 and index <= #entries and index <= firstVisibleRow * columns + visibleItemCount + columns then if options.onSelect(entries[index], index) then return end end end end end end end local function drawPage(base_id) local pageItems = getPageItems(base_id) local defaultIcon = getFallbackIcon() local backIcon = getBackButton() local backWidth, backHeight = imageSize(backIcon) local backX = 1 local pageItemIds = {} for i = 1, #pageItems do pageItemIds[i] = pageItems[i].id end runScrollableGrid({ refreshSeconds = 5, getInitialEntries = function() return pageItems end, getInitialCounts = function() return makeZeroCounts(pageItemIds) end, createRefreshJob = function(currentEntries) local job = createAsyncMeItemCountsJob(pageItemIds, 3) return { step = function() local isDone, itemCounts = job.step() if isDone then return true, currentEntries, itemCounts end return false end, } end, getId = function(entry) return entry.id end, getIcon = function(entry) return getItemIcon(entry) or defaultIcon end, drawChrome = function() drawNfpScaled(frame.buffer, backIcon, cellToPixelX(backX), 1) end, handleChromeTouch = function(x, y) if x >= backX and x < backX + backWidth and y >= 1 and y <= backHeight then drawOverview() return true end return false end, onSelect = function() return false end, }) end drawOverview = function() local items = getBaseItemIds() local defaultIcon = getFallbackIcon() local function sortOverviewItems(itemCounts) local sortedItemIds = {} for i = 1, #items do sortedItemIds[i] = items[i] end table.sort(sortedItemIds, function(a, b) local countA = itemCounts[a] or 0 local countB = itemCounts[b] or 0 if countA ~= countB then return countA > countB end return a < b end) return sortedItemIds end runScrollableGrid({ refreshSeconds = 30, getInitialEntries = function() if overviewCachedSortedItemIds and #overviewCachedSortedItemIds > 0 then return overviewCachedSortedItemIds end return sortOverviewItems(overviewCachedCounts) end, getInitialCounts = function() return overviewCachedCounts end, getInitialScrollState = function() return overviewScrollCurrentRow, overviewScrollTargetRow end, setScrollState = function(currentRow, targetRow) overviewScrollCurrentRow = currentRow overviewScrollTargetRow = targetRow end, createRefreshJob = function() local job = createAsyncMeItemCountsJob(items, 6) return { step = function() local isDone, itemCounts = job.step() if isDone then overviewCachedCounts = itemCounts overviewCachedSortedItemIds = sortOverviewItems(overviewCachedCounts) return true, overviewCachedSortedItemIds, overviewCachedCounts end return false end, } end, getId = function(entry) return entry end, getIcon = function(entry) return getItemIcon(getItemById(entry)) or defaultIcon end, onSelect = function(entry) drawPage(entry) return true end, }) end drawOverview() term.redirect(oldTerm)