1193 lines
32 KiB
Lua
1193 lines
32 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 chainAllItemsByBaseId
|
|
local baseItemIds
|
|
local allChainItemIds
|
|
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 globalItemCounts = {}
|
|
local globalCountRefreshInterval = 5
|
|
local globalCountRefreshJob
|
|
local globalCountRefreshTimer
|
|
local globalCountLastRefreshAt = 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 getCompressionLevel(itemId, stage)
|
|
if stage == "item" then
|
|
return 0
|
|
end
|
|
|
|
if type(stage) == "string" then
|
|
local level = tonumber(stage:match("^(%d+)x$"))
|
|
if level then
|
|
return level
|
|
end
|
|
end
|
|
|
|
if type(itemId) == "string" then
|
|
local level = tonumber(itemId:match("_(%d+)x$"))
|
|
if level then
|
|
return level
|
|
end
|
|
end
|
|
|
|
return 0
|
|
end
|
|
|
|
local function createChainItem(itemId, iconNfp, stage)
|
|
return {
|
|
id = itemId,
|
|
icon_nfp = iconNfp,
|
|
stage = stage,
|
|
compression_level = getCompressionLevel(itemId, stage),
|
|
equivalent_factor = 1,
|
|
}
|
|
end
|
|
|
|
local function applyChainEquivalentFactors(baseId, orderedItems)
|
|
local baseIndex = nil
|
|
|
|
for i = 1, #orderedItems do
|
|
if orderedItems[i].id == baseId then
|
|
baseIndex = i
|
|
break
|
|
end
|
|
end
|
|
|
|
assert(baseIndex, "Missing base item in chain: " .. tostring(baseId))
|
|
|
|
for i = 1, #orderedItems do
|
|
orderedItems[i].equivalent_factor = 9 ^ (i - baseIndex)
|
|
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 = {}
|
|
chainAllItemsByBaseId = {}
|
|
baseItemIds = {}
|
|
allChainItemIds = {}
|
|
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 orderedItems = {}
|
|
local pageItems = {}
|
|
|
|
if chain.base_id then
|
|
for _, item in ipairs(chain.items or {}) do
|
|
local chainItem = createChainItem(item.item_id, item.icon_nfp_16x16, item.stage)
|
|
orderedItems[#orderedItems + 1] = chainItem
|
|
itemById[item.item_id] = chainItem
|
|
allChainItemIds[#allChainItemIds + 1] = item.item_id
|
|
end
|
|
|
|
else
|
|
local compactItems = nil
|
|
|
|
if type(chain[2]) == "number" then
|
|
compactItems = chain[3] or {}
|
|
elseif type(chain[2]) == "table" then
|
|
compactItems = chain[2]
|
|
elseif type(chain[2]) == "string" and type(chain[3]) == "table" then
|
|
orderedItems[1] = createChainItem(baseId, chain[2], "item")
|
|
itemById[baseId] = orderedItems[1]
|
|
allChainItemIds[#allChainItemIds + 1] = baseId
|
|
compactItems = chain[3]
|
|
else
|
|
error("Invalid compact chain entry for " .. tostring(baseId))
|
|
end
|
|
|
|
for i = 1, #compactItems do
|
|
local compactItem = compactItems[i]
|
|
local chainItem = createChainItem(compactItem[1], compactItem[2], compactItem[3])
|
|
orderedItems[#orderedItems + 1] = chainItem
|
|
itemById[compactItem[1]] = chainItem
|
|
allChainItemIds[#allChainItemIds + 1] = compactItem[1]
|
|
end
|
|
end
|
|
|
|
applyChainEquivalentFactors(baseId, orderedItems)
|
|
|
|
for i = 1, #orderedItems do
|
|
pageItems[#pageItems + 1] = orderedItems[i]
|
|
end
|
|
|
|
chainAllItemsByBaseId[baseId] = orderedItems
|
|
chainItemsByBaseId[baseId] = pageItems
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function getPageItems(base_id)
|
|
ensureChainsLoaded()
|
|
|
|
return chainItemsByBaseId[base_id or defaultBaseId] or {}
|
|
end
|
|
|
|
local function getAllChainItems(base_id)
|
|
ensureChainsLoaded()
|
|
|
|
return chainAllItemsByBaseId[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 listBridgeItems(bridge)
|
|
local listItems = bridge.listItems or bridge.getItems or bridge.listAvailableItems
|
|
|
|
if not listItems then
|
|
return nil
|
|
end
|
|
|
|
local ok, items = pcall(listItems, {})
|
|
if ok and type(items) == "table" then
|
|
local normalized = {}
|
|
|
|
for _, item in pairs(items) do
|
|
normalized[#normalized + 1] = item
|
|
end
|
|
|
|
return normalized
|
|
end
|
|
|
|
ok, items = pcall(listItems)
|
|
if ok and type(items) == "table" then
|
|
local normalized = {}
|
|
|
|
for _, item in pairs(items) do
|
|
normalized[#normalized + 1] = item
|
|
end
|
|
|
|
return normalized
|
|
end
|
|
|
|
return nil
|
|
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 snapshot = listBridgeItems(bridge)
|
|
if not snapshot then
|
|
return counts
|
|
end
|
|
|
|
local wanted = {}
|
|
|
|
for i = 1, #itemIds do
|
|
wanted[itemIds[i]] = true
|
|
end
|
|
|
|
for i = 1, #snapshot do
|
|
local item = snapshot[i]
|
|
local itemId = item and (item.name or item.id or item.item_id)
|
|
|
|
if itemId and wanted[itemId] then
|
|
counts[itemId] = tonumber(item.amount or item.count or item.qty) or 0
|
|
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 createAsyncCountsFromSnapshotJob(itemIds, snapshot, batchSize, baseCounts)
|
|
local counts = {}
|
|
local index = 1
|
|
local wanted = {}
|
|
|
|
batchSize = math.max(1, math.floor(tonumber(batchSize) or 1))
|
|
|
|
for i = 1, #itemIds do
|
|
wanted[itemIds[i]] = true
|
|
counts[itemIds[i]] = 0
|
|
end
|
|
|
|
if type(baseCounts) == "table" then
|
|
for itemId, count in pairs(baseCounts) do
|
|
if wanted[itemId] then
|
|
counts[itemId] = tonumber(count) or 0
|
|
end
|
|
end
|
|
end
|
|
|
|
return {
|
|
step = function()
|
|
local lastIndex = math.min(#snapshot, index + batchSize - 1)
|
|
|
|
for i = index, lastIndex do
|
|
local item = snapshot[i]
|
|
local itemId = item and (item.name or item.id or item.item_id)
|
|
|
|
if itemId and wanted[itemId] then
|
|
counts[itemId] = tonumber(item.amount or item.count or item.qty) or 0
|
|
end
|
|
end
|
|
|
|
index = lastIndex + 1
|
|
return index > #snapshot, counts
|
|
end,
|
|
}
|
|
end
|
|
|
|
local function getGlobalItemCounts()
|
|
ensureChainsLoaded()
|
|
return globalItemCounts
|
|
end
|
|
|
|
local function hasGlobalItemCounts()
|
|
return next(globalItemCounts) ~= nil
|
|
end
|
|
|
|
local function getBaseEquivalentCount(baseId, itemCounts)
|
|
local total = 0
|
|
local chainItems = getAllChainItems(baseId)
|
|
|
|
for i = 1, #chainItems do
|
|
local item = chainItems[i]
|
|
total = total + (tonumber(itemCounts[item.id]) or 0) * (item.equivalent_factor or 1)
|
|
end
|
|
|
|
return total
|
|
end
|
|
|
|
local function getEquivalentLevelCounts(baseId, pageItems, itemCounts)
|
|
local equivalentCounts = {}
|
|
local baseEquivalentCount = getBaseEquivalentCount(baseId, itemCounts)
|
|
|
|
for i = 1, #pageItems do
|
|
local item = pageItems[i]
|
|
local factor = item.equivalent_factor or 1
|
|
equivalentCounts[item.id] = math.floor(baseEquivalentCount / factor)
|
|
end
|
|
|
|
return equivalentCounts
|
|
end
|
|
|
|
local function getEquivalentBaseCounts(baseIds, itemCounts)
|
|
local equivalentCounts = {}
|
|
|
|
for i = 1, #baseIds do
|
|
local baseId = baseIds[i]
|
|
equivalentCounts[baseId] = getBaseEquivalentCount(baseId, itemCounts)
|
|
end
|
|
|
|
return equivalentCounts
|
|
end
|
|
|
|
local function createGlobalCountRefreshJob()
|
|
ensureChainsLoaded()
|
|
|
|
local bridge = ensureStorageBridge()
|
|
if not bridge then
|
|
return nil
|
|
end
|
|
|
|
local snapshot = listBridgeItems(bridge)
|
|
if not snapshot then
|
|
return nil
|
|
end
|
|
|
|
local job = createAsyncCountsFromSnapshotJob(allChainItemIds or {}, snapshot, 25, globalItemCounts)
|
|
|
|
return {
|
|
step = function()
|
|
local isDone, counts = job.step()
|
|
|
|
if isDone then
|
|
globalItemCounts = counts
|
|
globalCountLastRefreshAt = os.epoch("utc")
|
|
end
|
|
|
|
return isDone, counts
|
|
end,
|
|
}
|
|
end
|
|
|
|
local function shouldStartGlobalCountRefresh()
|
|
if globalCountRefreshJob then
|
|
return false
|
|
end
|
|
|
|
if not hasGlobalItemCounts() then
|
|
return true
|
|
end
|
|
|
|
return os.epoch("utc") - globalCountLastRefreshAt >= globalCountRefreshInterval * 1000
|
|
end
|
|
|
|
local function startGlobalCountRefreshIfNeeded(force)
|
|
if globalCountRefreshJob then
|
|
return false
|
|
end
|
|
|
|
if not force and not shouldStartGlobalCountRefresh() then
|
|
return false
|
|
end
|
|
|
|
globalCountRefreshJob = createGlobalCountRefreshJob()
|
|
if not globalCountRefreshJob then
|
|
return false
|
|
end
|
|
|
|
globalCountRefreshTimer = os.startTimer(0.01)
|
|
return true
|
|
end
|
|
|
|
local function stepGlobalCountRefresh(timerId)
|
|
if not globalCountRefreshJob or timerId ~= globalCountRefreshTimer then
|
|
return false, false
|
|
end
|
|
|
|
local isDone = globalCountRefreshJob.step()
|
|
|
|
if isDone then
|
|
globalCountRefreshJob = nil
|
|
globalCountRefreshTimer = nil
|
|
return true, true
|
|
end
|
|
|
|
globalCountRefreshTimer = os.startTimer(0.01)
|
|
return true, false
|
|
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, item)
|
|
-- 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 compressionLevel = item and tonumber(item.compression_level) or 0
|
|
|
|
if compressionLevel > 0 and y <= frame.buffer.height and y + 8 >= 1 then
|
|
mf.writeOn(frame, tostring(compressionLevel), colors.red, x + 15, y, {
|
|
font = "fonts/PixelPlace",
|
|
textAlign = "right",
|
|
anchorHor = "right",
|
|
anchorVer = "top",
|
|
condense = true,
|
|
scale = 1,
|
|
})
|
|
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 pendingCacheSync = false
|
|
|
|
local columns = 5
|
|
local rowsPerView = 3
|
|
local baseCellX = 3
|
|
local baseCellY = 2
|
|
local colStepCells = 8+3
|
|
local rowStepCells = 5+3
|
|
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 syncFromCache()
|
|
entries, counts = options.getEntriesAndCountsFromCache(entries, counts)
|
|
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,
|
|
entry
|
|
)
|
|
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
|
|
|
|
syncFromCache()
|
|
renderGrid()
|
|
|
|
if options.shouldStartRefreshImmediately == nil or options.shouldStartRefreshImmediately(entries, counts) then
|
|
startGlobalCountRefreshIfNeeded(false)
|
|
end
|
|
|
|
refreshTimer = os.startTimer(options.refreshSeconds or globalCountRefreshInterval)
|
|
|
|
while true do
|
|
local event, p1, x, y = os.pullEvent()
|
|
|
|
if event == "timer" and p1 == refreshTimer then
|
|
startGlobalCountRefreshIfNeeded(false)
|
|
refreshTimer = os.startTimer(options.refreshSeconds or globalCountRefreshInterval)
|
|
scheduleAnimation()
|
|
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()
|
|
|
|
if not animationTimer and pendingCacheSync then
|
|
pendingCacheSync = false
|
|
syncFromCache()
|
|
renderGrid()
|
|
end
|
|
elseif event == "timer" then
|
|
local handled, isDone = stepGlobalCountRefresh(p1)
|
|
|
|
if handled and isDone then
|
|
if animationTimer or math.abs(targetScrollRow - currentScrollRow) > 0.001 then
|
|
pendingCacheSync = true
|
|
else
|
|
syncFromCache()
|
|
renderGrid()
|
|
end
|
|
end
|
|
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 = globalCountRefreshInterval,
|
|
getInitialEntries = function()
|
|
return pageItems
|
|
end,
|
|
getInitialCounts = function()
|
|
return getEquivalentLevelCounts(base_id, pageItems, getGlobalItemCounts())
|
|
end,
|
|
getEntriesAndCountsFromCache = function(currentEntries)
|
|
return currentEntries, getEquivalentLevelCounts(base_id, pageItems, getGlobalItemCounts())
|
|
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 = globalCountRefreshInterval,
|
|
getInitialEntries = function()
|
|
if overviewCachedSortedItemIds and #overviewCachedSortedItemIds > 0 then
|
|
return overviewCachedSortedItemIds
|
|
end
|
|
|
|
return sortOverviewItems(overviewCachedCounts)
|
|
end,
|
|
getInitialCounts = function()
|
|
local counts = getGlobalItemCounts()
|
|
|
|
if next(counts) ~= nil then
|
|
overviewCachedCounts = getEquivalentBaseCounts(items, counts)
|
|
end
|
|
|
|
return overviewCachedCounts
|
|
end,
|
|
getInitialScrollState = function()
|
|
return overviewScrollCurrentRow, overviewScrollTargetRow
|
|
end,
|
|
setScrollState = function(currentRow, targetRow)
|
|
overviewScrollCurrentRow = currentRow
|
|
overviewScrollTargetRow = targetRow
|
|
end,
|
|
getEntriesAndCountsFromCache = function()
|
|
overviewCachedCounts = getEquivalentBaseCounts(items, getGlobalItemCounts())
|
|
overviewCachedSortedItemIds = sortOverviewItems(overviewCachedCounts)
|
|
return overviewCachedSortedItemIds, overviewCachedCounts
|
|
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)
|