Files
compressed-count-cc/compcount.lua
T
2026-06-01 15:10:15 +02:00

998 lines
27 KiB
Lua

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)