Files
Inventory-Manager-CC/manager/display.lua

2011 lines
74 KiB
Lua

-- manager/display.lua — Dashboard rendering using Opus UI framework
-- Usage: local display = dofile("manager/display.lua")(ctx)
--
-- Requires Opus UI framework (opus.ui, opus.event)
-- Falls back to raw monitor drawing if Opus is not available.
return function(ctx)
local UI = require('opus.ui')
local cfg = ctx.cfg
local state = ctx.state
local log = ctx.log
local ops = ctx.ops
local cache = state.cache
local activity = state.activity
local D = {}
-------------------------------------------------
-- Monitor handles
-------------------------------------------------
D.mon = nil
D.monName = nil
D.smelterMon = nil
D.smelterMonName = nil
D.billboardMon = nil
D.billboardMonName = nil
-- Opus UI devices and pages
local mainDevice = nil
local smelterDevice = nil
local mainPage = nil
local smelterPage = nil
-------------------------------------------------
-- Local UI state
-------------------------------------------------
local selectedAmount = 1
local amountOptions = {1, 4, 8, 16, 32, 64}
local searchQuery = ""
local showKeyboard = false
local orderPopupItem = nil
local orderPopupShort = nil
local orderPopupQty = 1
local smelterView = "status"
-------------------------------------------------
-- Helpers
-------------------------------------------------
local function shortName(fullName)
local s = fullName:gsub("^minecraft:", ""):gsub("_", " ")
return s:sub(1,1):upper() .. s:sub(2)
end
local function getFilteredItems()
state.ensureItemList()
local filtered = {}
for _, item in ipairs(cache.itemList) do
if searchQuery == "" then
table.insert(filtered, item)
else
local lower = item.name:lower():gsub("minecraft:", ""):gsub("_", " ")
if lower:find(searchQuery:lower(), 1, true) then
table.insert(filtered, item)
end
end
end
return filtered
end
local function getActivityString()
local parts = {}
if activity.sorting then table.insert(parts, "SORTING") end
if activity.dispensing then table.insert(parts, "DISPENSING") end
if activity.smelting then table.insert(parts, "SMELTING") end
if activity.scanning then table.insert(parts, "SCANNING") end
if activity.defragging then table.insert(parts, "DEFRAG") end
if activity.composting then table.insert(parts, "COMPOST") end
if activity.discarding then table.insert(parts, "DISCARD") end
if activity.autocrafting then table.insert(parts, "AUTOCRAFT") end
if #parts > 0 then
return table.concat(parts, " | ")
end
return ""
end
local function getBottomMessage()
if activity.dispensing then return "DISPENSING..."
elseif activity.smelting then return "SMELTING..."
elseif activity.sorting then return "SORTING BARREL..."
elseif activity.defragging then return "DEFRAGMENTING..."
elseif activity.composting then return "COMPOSTING..."
elseif activity.discarding then return "DISCARDING EXCESS..."
elseif activity.autocrafting then return "AUTO-CRAFTING..."
end
return "Tap item to order"
end
local function getStorageBarColor()
if cache.usedRatio > 0.9 then return colors.red
elseif cache.usedRatio > 0.7 then return colors.orange
elseif cache.usedRatio > 0.5 then return colors.yellow
end
return colors.lime
end
local function getStockBarColor(_, ratio)
if ratio < 0.25 then return colors.red
elseif ratio < 0.5 then return colors.orange
end
return colors.lime
end
-------------------------------------------------
-- Monitor setup
-------------------------------------------------
--- Track peripheral names already assigned to a role.
-- A single physical monitor can appear under multiple names (e.g. "left"
-- AND "monitor_0") when it is both side-attached and on a wired modem.
-- We detect aliases by mutating text scale on one name and checking
-- whether a known monitor's getSize() changes.
D._usedMonitorNames = {} -- set of names known to be taken
--- Detect whether 'candidateName' is the same physical block as 'knownMon'
-- (a wrapped peripheral table). We temporarily set the candidate's text
-- scale to an extreme value and check whether the known monitor reports a
-- size change. If it does, they share the same hardware.
local function isMonitorAlias(candidateName, knownMon)
if not candidateName or not knownMon then return false end
local refW, refH = knownMon.getSize()
-- Save candidate's current scale (getTextScale available CC:T 1.94+)
local ok, origScale = pcall(peripheral.call, candidateName, "getTextScale")
if not ok then origScale = 1 end
-- Pick a test scale far from the current one
local testScale = (origScale >= 3) and 0.5 or 5
pcall(peripheral.call, candidateName, "setTextScale", testScale)
local newW, newH = knownMon.getSize()
-- Restore
pcall(peripheral.call, candidateName, "setTextScale", origScale)
return newW ~= refW or newH ~= refH
end
--- Register all peripheral names that refer to the same physical block as
-- 'knownName' / 'knownMon'. This populates D._usedMonitorNames so that
-- later auto-detection can skip aliases by simple table lookup.
local function registerMonitorAliases(knownName, knownMon)
D._usedMonitorNames[knownName] = true
for _, name in ipairs(peripheral.getNames()) do
if name ~= knownName and peripheral.getType(name) == "monitor" then
if isMonitorAlias(name, knownMon) then
D._usedMonitorNames[name] = true
log.debug("DISPLAY", "Monitor alias: %s => %s", name, knownName)
end
end
end
end
function D.setupMonitor()
local mon = peripheral.wrap(cfg.MONITOR_SIDE)
if not mon or not mon.setTextScale then
-- Fallback: find any monitor
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "monitor" then
mon = peripheral.wrap(name)
if mon and mon.setTextScale then
D.mon = mon
D.monName = name
break
end
mon = nil
end
end
else
D.mon = mon
D.monName = cfg.MONITOR_SIDE
end
if not D.mon then return false end
mainDevice = UI.Device({
device = D.mon,
textScale = 0.5,
})
-- Register this monitor and all its aliases as taken
registerMonitorAliases(D.monName, D.mon)
return true
end
function D.setupSmelterMonitor()
-- Try configured side first
local mon = peripheral.wrap(cfg.SMELTER_MONITOR_SIDE)
local monName = cfg.SMELTER_MONITOR_SIDE
if not mon or not mon.setTextScale or D._usedMonitorNames[monName] then
mon = nil
monName = nil
-- Fallback: find any unused monitor
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "monitor" and not D._usedMonitorNames[name] then
mon = peripheral.wrap(name)
if mon and mon.setTextScale then
monName = name
break
end
mon = nil
end
end
end
if not mon then return false end
D.smelterMon = mon
D.smelterMonName = monName
smelterDevice = UI.Device({
device = D.smelterMon,
textScale = 0.5,
})
-- Register this monitor and all its aliases as taken
registerMonitorAliases(D.smelterMonName, D.smelterMon)
return true
end
function D.setupBillboardMonitor()
local scale = cfg.BILLBOARD_TEXT_SCALE or 1
-- If explicitly configured, use that name
if cfg.BILLBOARD_MONITOR and cfg.BILLBOARD_MONITOR ~= "" then
local mon = peripheral.wrap(cfg.BILLBOARD_MONITOR)
if mon and mon.setTextScale then
D.billboardMon = mon
D.billboardMonName = cfg.BILLBOARD_MONITOR
D.billboardMon.setTextScale(scale)
registerMonitorAliases(D.billboardMonName, D.billboardMon)
return true
end
return false
end
-- Auto-detect: find any monitor not already used by main/smelter
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "monitor" and not D._usedMonitorNames[name] then
local mon = peripheral.wrap(name)
if mon and mon.setTextScale then
D.billboardMon = mon
D.billboardMonName = name
D.billboardMon.setTextScale(scale)
registerMonitorAliases(D.billboardMonName, D.billboardMon)
return true
end
end
end
return false
end
-------------------------------------------------
-- Build main dashboard page
-------------------------------------------------
local function buildMainPage()
if not mainDevice then return end
mainPage = UI.Page {
backgroundColor = colors.black,
textColor = colors.white,
-- Title bar (row 1)
titleBar = UI.Window {
x = 1, y = 1, ex = -1, height = 1,
backgroundColor = colors.blue,
draw = function(self)
self:clear(colors.blue)
self:centeredWrite(1, " ** INVENTORY MANAGER ** ", colors.blue, colors.white)
end,
},
-- Status bar (row 2)
statusRow = UI.Window {
x = 1, y = 2, ex = -1, height = 1,
backgroundColor = colors.gray,
draw = function(self)
self:clear(colors.gray)
local parts = {}
table.insert(parts, string.format(" Chests: %d", cache.chestCount))
table.insert(parts, cache.dropperOk and "Dropper: OK" or "Dropper: --")
table.insert(parts, cache.barrelOk and "Barrel: OK" or "Barrel: --")
if cache.furnaceCount and cache.furnaceCount > 0 then
table.insert(parts, string.format("Furnaces: %d", cache.furnaceCount))
end
self:write(2, 1, table.concat(parts, " | "), colors.gray, colors.white)
local actStr = getActivityString()
if #actStr > 0 then
actStr = " " .. actStr .. " "
self:write(self.width - #actStr + 1, 1, actStr, colors.orange, colors.white)
end
end,
},
-- Divider (row 3)
divider = UI.Window {
x = 1, y = 3, ex = -1, height = 1,
backgroundColor = colors.lightBlue,
draw = function(self)
self:clear(colors.lightBlue)
local dash = string.rep("-", math.min(self.width - 4, 60))
self:centeredWrite(1, dash, colors.lightBlue, colors.cyan)
end,
},
-- Storage label + bar (row 4)
storageRow = UI.Window {
x = 1, y = 4, ex = -1, height = 1,
backgroundColor = colors.black,
draw = function(self)
self:clear(colors.black)
local label = string.format(" Storage: %d/%d slots (%d free)",
cache.usedSlots, cache.totalSlots, cache.freeSlots)
self:write(2, 1, label, colors.black, colors.lightGray)
local barStart = #label + 4
local barWidth = self.width - barStart - 2
if barWidth > 4 then
local ratio = cache.usedRatio or 0
local filled = math.floor(ratio * barWidth)
local barColor = getStorageBarColor()
-- Draw the bar
if filled > 0 then
self:write(barStart, 1, string.rep(" ", filled), barColor)
end
if barWidth - filled > 0 then
self:write(barStart + filled, 1,
string.rep(" ", barWidth - filled), colors.gray)
end
-- Overlay percentage text
local pctStr = string.format(" %d%% ", math.floor(ratio * 100))
local pctX = barStart + math.floor(barWidth / 2) - math.floor(#pctStr / 2)
for ci = 1, #pctStr do
local cx = pctX + ci - 1
if cx >= barStart and cx < barStart + barWidth then
local bg = (cx - barStart) < filled and barColor or colors.gray
self:write(cx, 1, pctStr:sub(ci, ci), bg, colors.white)
end
end
end
end,
},
-- Amount selector row (row 5)
amountRow = UI.Window {
x = 1, y = 5, ex = -1, height = 1,
backgroundColor = colors.black,
draw = function(self)
self:clear(colors.black)
self:write(2, 1, "Qty:", colors.black, colors.lightGray)
-- Amount buttons are drawn as children
self:drawChildren()
end,
},
-- Search + refresh row (row 6)
searchRow = UI.Window {
x = 1, y = 6, ex = -1, height = 1,
backgroundColor = colors.black,
draw = function(self)
self:clear(colors.black)
-- Keyboard toggle button
local kbLabel = showKeyboard and " X " or " ? "
local kbBg = showKeyboard and colors.red or colors.purple
self:write(1, 1, kbLabel, kbBg, colors.white)
-- Search query display
local fieldW = math.floor(self.width * 0.4)
if fieldW < 10 then fieldW = 10 end
local queryDisplay = searchQuery
if showKeyboard then
queryDisplay = queryDisplay .. "|"
elseif queryDisplay == "" then
queryDisplay = "search..."
end
local displayText = queryDisplay:sub(1, fieldW)
displayText = displayText .. string.rep("_", math.max(0, fieldW - #displayText))
local tc = (searchQuery == "" and not showKeyboard) and colors.gray or colors.white
self:write(5, 1, displayText, colors.black, tc)
end,
eventHandler = function(self, event)
if event.type == 'mouse_click' then
showKeyboard = not showKeyboard
local page = self.parent
if showKeyboard then
UI.Window.enable(page.keyboard)
page.keyboard:raise()
page.keyboard:draw()
else
page.keyboard:disable()
page.alertBar:draw()
page.footerBar:draw()
page.bottomBar:draw()
end
self:draw()
page:sync()
return true
end
return UI.Window.eventHandler(self, event)
end,
},
refreshBtn = UI.Button {
y = 5, ex = -2,
text = "Refresh",
backgroundColor = colors.green,
backgroundFocusColor = colors.lime,
textColor = colors.white,
event = 'do_scan',
},
-- Item grid (rows 7 to h-3)
itemGrid = UI.ScrollingGrid {
x = 1, y = 7, ex = -1, ey = -4,
disableHeader = false,
headerHeight = 1,
headerBackgroundColor = colors.gray,
headerTextColor = colors.lightGray,
backgroundColor = colors.black,
alternateRowColor = colors.gray,
backgroundSelectedColor = colors.blue,
unfocusedBackgroundSelectedColor = colors.blue,
textColor = colors.white,
focusIndicator = '>',
sortColumn = 'total',
inverseSort = true,
columns = {
{ heading = '#', key = 'idx', width = 3 },
{ heading = 'Item', key = 'short' },
{ heading = 'Qty', key = 'qty', width = 6, textColor = colors.yellow },
{ heading = 'Stock', key = 'ratio', width = 12,
barColumn = true,
barColor = getStockBarColor,
barEmptyColor = colors.gray,
},
},
values = {},
getDisplayValues = function(_, row)
return {
idx = tostring(row.idx or ''),
short = row.short or '',
qty = tostring(row.total or 0),
ratio = row.ratio or 0,
}
end,
-- Single-click triggers grid_select (for monitor touch)
eventHandler = function(self, event)
if event.type == 'mouse_click' then
local handled = UI.Grid.eventHandler(self, event)
if handled and self.selected then
self:emit({ type = 'grid_select', selected = self.selected, element = self })
end
return handled
end
return UI.Grid.eventHandler(self, event)
end,
},
-- Alert / status area
alertBar = UI.Window {
x = 1, ey = -3, ex = -1, height = 1,
backgroundColor = colors.black,
draw = function(self)
self:clear(colors.black)
if #state.activeAlerts > 0 then
local alertIdx = math.floor(os.epoch("utc") / 2000) % #state.activeAlerts + 1
local a = state.activeAlerts[alertIdx]
local msg = string.format(" LOW STOCK: %s (%d/%d) ", a.label, a.current, a.min)
self:centeredWrite(1, msg, colors.red, colors.white)
elseif state.statusTimer > 0 and #state.statusMessage > 0 then
self:centeredWrite(1, state.statusMessage, colors.black, state.statusColor)
end
end,
},
-- Footer
footerBar = UI.Window {
x = 1, ey = -2, ex = -1, height = 1,
backgroundColor = colors.gray,
draw = function(self)
self:clear(colors.gray)
state.ensureItemList()
local footerLeft = string.format(" Total: %d items | %d types ",
cache.grandTotal, #cache.itemList)
self:write(2, 1, footerLeft, colors.gray, colors.white)
if searchQuery ~= "" then
local filteredItems = getFilteredItems()
local filterNote = string.format("| Showing %d ", #filteredItems)
self:write(2 + #footerLeft + 1, 1, filterNote, colors.gray, colors.yellow)
end
local timeStr = textutils.formatTime(os.time(), true)
self:write(self.width - #timeStr - 1, 1, timeStr, colors.gray, colors.lightGray)
end,
},
-- Bottom accent
bottomBar = UI.Window {
x = 1, ey = -1, ex = -1, height = 1,
backgroundColor = colors.blue,
draw = function(self)
self:clear(colors.blue)
self:centeredWrite(1, " " .. getBottomMessage() .. " ", colors.blue, colors.lightBlue)
end,
},
-- On-screen keyboard overlay (bottom 3 rows; starts disabled)
keyboard = UI.Window {
x = 1, ex = -1, ey = -1, height = 3,
backgroundColor = colors.black,
enable = function() end, -- prevent auto-enable; toggled manually
draw = function(self)
self:clear(colors.black)
local kbDefs = {
{ keys = {"Q","W","E","R","T","Y","U","I","O","P"}, specials = {{ label = " Bksp ", bg = colors.red, action = "kb_bksp" }} },
{ keys = {"A","S","D","F","G","H","J","K","L"}, specials = {{ label = " Done ", bg = colors.green, action = "kb_done" }} },
{ keys = {"Z","X","C","V","B","N","M"}, specials = {
{ label = " Space ", bg = colors.lightGray, action = "kb_space" },
{ label = " Clr ", bg = colors.orange, action = "kb_clear" },
}},
}
self._zones = {}
local keyW = 3
local keyGap = 1
for rowIdx, def in ipairs(kbDefs) do
local y = rowIdx
local keysW = #def.keys * keyW + math.max(0, #def.keys - 1) * keyGap
local specialsW = 0
for _, sp in ipairs(def.specials) do
specialsW = specialsW + keyGap + #sp.label
end
local rowW = keysW + specialsW
local x = math.floor((self.width - rowW) / 2) + 1
-- Draw letter keys
for ki, key in ipairs(def.keys) do
self:write(x, y, " " .. key .. " ", colors.gray, colors.white)
table.insert(self._zones, { x1 = x, y1 = y, x2 = x + keyW - 1, y2 = y, action = "kb_key", data = key:lower() })
x = x + keyW
if ki < #def.keys then x = x + keyGap end
end
-- Draw special keys
for _, sp in ipairs(def.specials) do
x = x + keyGap
self:write(x, y, sp.label, sp.bg, colors.white)
table.insert(self._zones, { x1 = x, y1 = y, x2 = x + #sp.label - 1, y2 = y, action = sp.action })
x = x + #sp.label
end
end
end,
eventHandler = function(self, event)
if event.type == 'mouse_click' then
if self._zones then
for _, zone in ipairs(self._zones) do
if event.x >= zone.x1 and event.x <= zone.x2
and event.y >= zone.y1 and event.y <= zone.y2 then
self:emit({ type = zone.action, data = zone.data, element = self })
return true
end
end
end
return true -- consume click even if no zone hit
end
return UI.Window.eventHandler(self, event)
end,
},
-- Order quantity popup (full-screen overlay; starts disabled)
orderPopup = UI.Window {
x = 1, y = 1, ex = -1, ey = -1,
backgroundColor = colors.gray,
enable = function() end, -- toggled manually
draw = function(self)
self:clear(colors.gray)
self._zones = {}
local dw = math.min(self.width - 2, 30)
local dh = 8
local dx = math.floor((self.width - dw) / 2) + 1
local dy = math.floor((self.height - dh) / 2) + 1
-- Title row (blue background)
local title = "Order: " .. (orderPopupShort or "?")
if #title > dw - 2 then title = title:sub(1, dw - 2) end
self:write(dx, dy, string.rep(" ", dw), colors.blue)
local titleX = dx + math.floor((dw - #title) / 2)
self:write(titleX, dy, title, colors.blue, colors.white)
-- Dialog body rows (gray background)
for row = 1, dh - 1 do
self:write(dx, dy + row, string.rep(" ", dw), colors.gray)
end
-- Quantity display (row 2)
local qtyStr = string.format("Quantity: %d", orderPopupQty)
local qtyX = dx + math.floor((dw - #qtyStr) / 2)
self:write(qtyX, dy + 2, qtyStr, colors.gray, colors.white)
-- Increment buttons (row 4): [-8] [-1] [+1] [+8]
local incBtns = {
{ label = " -8 ", delta = -8, bg = colors.red },
{ label = " -1 ", delta = -1, bg = colors.red },
{ label = " +1 ", delta = 1, bg = colors.green },
{ label = " +8 ", delta = 8, bg = colors.green },
}
local totalIncW = 0
for _, b in ipairs(incBtns) do totalIncW = totalIncW + #b.label end
totalIncW = totalIncW + (#incBtns - 1) * 2
local incX = dx + math.floor((dw - totalIncW) / 2)
local incRow = dy + 4
for _, b in ipairs(incBtns) do
self:write(incX, incRow, b.label, b.bg, colors.white)
table.insert(self._zones, {
x1 = incX, y1 = incRow,
x2 = incX + #b.label - 1, y2 = incRow,
action = "order_delta", data = b.delta,
})
incX = incX + #b.label + 2
end
-- Preset buttons (row 5): [1] [4] [8] [16] [32] [64]
local presets = {1, 4, 8, 16, 32, 64}
local totalPreW = 0
for _, p in ipairs(presets) do totalPreW = totalPreW + #tostring(p) + 2 end
totalPreW = totalPreW + (#presets - 1)
local preX = dx + math.floor((dw - totalPreW) / 2)
local preRow = dy + 5
for _, p in ipairs(presets) do
local label = " " .. tostring(p) .. " "
local bg = (p == orderPopupQty) and colors.cyan or colors.lightGray
local fg = (p == orderPopupQty) and colors.white or colors.black
self:write(preX, preRow, label, bg, fg)
table.insert(self._zones, {
x1 = preX, y1 = preRow,
x2 = preX + #label - 1, y2 = preRow,
action = "order_set", data = p,
})
preX = preX + #label + 1
end
-- Action buttons (row 7): [Cancel] [Order]
local cancelLabel = " Cancel "
local orderLabel = " Order "
local actRow = dy + 7
local cancelX = dx + math.floor(dw / 4) - math.floor(#cancelLabel / 2)
local orderX = dx + math.floor(3 * dw / 4) - math.floor(#orderLabel / 2)
self:write(cancelX, actRow, cancelLabel, colors.red, colors.white)
table.insert(self._zones, {
x1 = cancelX, y1 = actRow,
x2 = cancelX + #cancelLabel - 1, y2 = actRow,
action = "order_cancel",
})
self:write(orderX, actRow, orderLabel, colors.lime, colors.white)
table.insert(self._zones, {
x1 = orderX, y1 = actRow,
x2 = orderX + #orderLabel - 1, y2 = actRow,
action = "order_confirm",
})
end,
eventHandler = function(self, event)
if event.type == 'mouse_click' then
if self._zones then
for _, zone in ipairs(self._zones) do
if event.x >= zone.x1 and event.x <= zone.x2
and event.y >= zone.y1 and event.y <= zone.y2 then
self:emit({ type = zone.action, data = zone.data, element = self })
return true
end
end
end
-- Click outside dialog dismisses popup
self:emit({ type = 'order_cancel', element = self })
return true
end
return UI.Window.eventHandler(self, event)
end,
},
-- Notification overlay
notification = UI.Notification {
anchor = 'bottom',
},
eventHandler = function(self, event)
if event.type == 'kb_key' then
if #searchQuery < 30 then
searchQuery = searchQuery .. event.data
end
D.refreshItemGrid()
self.searchRow:draw()
self.footerBar:draw()
self:sync()
return true
elseif event.type == 'kb_bksp' then
if #searchQuery > 0 then
searchQuery = searchQuery:sub(1, -2)
end
D.refreshItemGrid()
self.searchRow:draw()
self.footerBar:draw()
self:sync()
return true
elseif event.type == 'kb_space' then
if #searchQuery < 30 then
searchQuery = searchQuery .. " "
end
D.refreshItemGrid()
self.searchRow:draw()
self.footerBar:draw()
self:sync()
return true
elseif event.type == 'kb_done' then
showKeyboard = false
self.keyboard:disable()
self.searchRow:draw()
self.alertBar:draw()
self.footerBar:draw()
self.bottomBar:draw()
self:sync()
return true
elseif event.type == 'kb_clear' then
searchQuery = ""
showKeyboard = false
self.keyboard:disable()
D.refreshItemGrid()
self.searchRow:draw()
self.alertBar:draw()
self.footerBar:draw()
self.bottomBar:draw()
self:sync()
return true
elseif event.type == 'grid_select' then
local row = event.selected
if row and row.name then
-- Hide keyboard if showing
if showKeyboard then
showKeyboard = false
self.keyboard:disable()
end
-- Show order popup
orderPopupItem = row.name
orderPopupShort = shortName(row.name)
orderPopupQty = selectedAmount
UI.Window.enable(self.orderPopup)
self.orderPopup:raise()
self.orderPopup:draw()
self:sync()
end
return true
elseif event.type == 'order_delta' then
orderPopupQty = math.max(1, math.min(999, orderPopupQty + event.data))
self.orderPopup:draw()
self:sync()
return true
elseif event.type == 'order_set' then
orderPopupQty = event.data
self.orderPopup:draw()
self:sync()
return true
elseif event.type == 'order_confirm' then
if orderPopupItem then
local short = shortName(orderPopupItem)
state.statusMessage = string.format("Ordering %s x%d...", short, orderPopupQty)
state.statusColor = colors.cyan
state.statusTimer = 10
activity.dispensing = true
state.needsRedraw = true
ops.orderItem(orderPopupItem, orderPopupQty)
end
self.orderPopup:disable()
orderPopupItem = nil
self:draw()
self:sync()
return true
elseif event.type == 'order_cancel' then
self.orderPopup:disable()
orderPopupItem = nil
self:draw()
self:sync()
return true
elseif event.type == 'amount_select' then
selectedAmount = event.button.amount
D.updateAmountButtons()
self:sync()
return true
elseif event.type == 'do_scan' then
state.statusMessage = "Refreshing..."
state.statusColor = colors.cyan
state.statusTimer = 3
state.needsRedraw = true
return true
end
return UI.Page.eventHandler(self, event)
end,
}
-- Add amount buttons as children of amountRow
local btnX = 7
for _, amt in ipairs(amountOptions) do
local uid = 'amt_' .. amt
mainPage.amountRow[uid] = UI.Button {
x = btnX, y = 1,
text = tostring(amt),
backgroundColor = (amt == selectedAmount) and colors.cyan or colors.gray,
backgroundFocusColor = colors.cyan,
textColor = (amt == selectedAmount) and colors.white or colors.lightGray,
textFocusColor = colors.white,
event = 'amount_select',
amount = amt,
}
btnX = btnX + #tostring(amt) + 4
end
-- Attach to device (must resize to recompute all child dimensions
-- from the monitor device, since Page:postInit defaulted to UI.term)
mainDevice.currentPage = mainPage
mainPage.parent = mainDevice
mainPage:resize()
mainPage:setParent()
mainPage:enable()
end
function D.updateAmountButtons()
if not mainPage then return end
for _, amt in ipairs(amountOptions) do
local btn = mainPage.amountRow['amt_' .. amt]
if btn then
btn.backgroundColor = (amt == selectedAmount) and colors.cyan or colors.gray
btn.textColor = (amt == selectedAmount) and colors.white or colors.lightGray
btn:draw()
end
end
end
function D.refreshItemGrid()
if not mainPage then return end
local filteredItems = getFilteredItems()
-- Find max count for ratio calc
local maxCount = 0
for _, item in ipairs(filteredItems) do
if item.total > maxCount then maxCount = item.total end
end
if maxCount == 0 then maxCount = 1 end
local gridValues = {}
for i, item in ipairs(filteredItems) do
table.insert(gridValues, {
idx = i,
name = item.name,
short = shortName(item.name),
total = item.total,
qty = tostring(item.total),
ratio = item.total / maxCount,
})
end
mainPage.itemGrid:setValues(gridValues)
end
-------------------------------------------------
-- Build smelter dashboard page
-------------------------------------------------
local function buildSmelterPage()
if not smelterDevice then return end
smelterPage = UI.Page {
backgroundColor = colors.black,
textColor = colors.white,
-- Title bar
titleBar = UI.Window {
x = 1, y = 1, ex = -1, height = 1,
backgroundColor = colors.purple,
draw = function(self)
self:clear(colors.purple)
self:centeredWrite(1, " ** SMELTER DASHBOARD ** ", colors.purple, colors.white)
end,
},
-- Status bar with pause toggle
statusRow = UI.Window {
x = 1, y = 2, ex = -1, height = 1,
backgroundColor = colors.gray,
draw = function(self)
self:clear(colors.gray)
local activeCount = 0
for _, fs in ipairs(cache.furnaceStatus or {}) do
if fs.active then activeCount = activeCount + 1 end
end
local statusStr = string.format(" Furnaces: %d Active: %d",
cache.furnaceCount or 0, activeCount)
self:write(2, 1, statusStr, colors.gray, colors.white)
local pauseLabel = state.smeltingPaused and " PAUSED " or " ACTIVE "
local pauseBg = state.smeltingPaused and colors.red or colors.lime
local pauseFg = state.smeltingPaused and colors.white or colors.black
self:write(self.width - #pauseLabel + 1, 1, pauseLabel, pauseBg, pauseFg)
end,
eventHandler = function(self, event)
if event.type == 'mouse_click' then
local pauseLabel = state.smeltingPaused and " PAUSED " or " ACTIVE "
local pauseStart = self.width - #pauseLabel + 1
if event.x >= pauseStart then
state.smeltingPaused = not state.smeltingPaused
log.debug("UI", "Smelting %s",
state.smeltingPaused and "PAUSED" or "RESUMED")
ops.saveDisabledRecipes()
state.smelterNeedsRedraw = true
state.needsRedraw = true
return true
end
end
return UI.Window.eventHandler(self, event)
end,
},
-- Divider
divider = UI.Window {
x = 1, y = 3, ex = -1, height = 1,
backgroundColor = colors.magenta,
draw = function(self)
self:clear(colors.magenta)
local dash = string.rep("-", math.min(self.width - 4, 60))
self:centeredWrite(1, dash, colors.magenta, colors.pink)
end,
},
-- Tabs container
tabs = UI.Tabs {
x = 1, y = 4, ex = -1, ey = -3,
barBackgroundColor = colors.black,
selectedBackgroundColor = colors.purple,
unselectedBackgroundColor = colors.gray,
eventHandler = function(self, event)
if event.type == 'tab_change' then
local titleMap = {
Status = 'status', Smelt = 'smelt',
Craft = 'craft', Missing = 'missing',
}
if event.tab and event.tab.text then
smelterView = titleMap[event.tab.text] or smelterView
end
D.refreshSmelterData()
local page = self.parent
if page then
page.smelterFooter:draw()
page.bottomBar:draw()
end
end
return UI.Tabs.eventHandler(self, event)
end,
-- Status tab
statusTab = UI.Tab {
index = 1,
title = "Status",
noFill = true,
backgroundColor = colors.black,
grid = UI.ScrollingGrid {
y = 1, ey = -1,
disableHeader = false,
headerBackgroundColor = colors.gray,
headerTextColor = colors.lightGray,
backgroundColor = colors.black,
alternateRowColor = colors.gray,
textColor = colors.white,
sortColumn = 'idx',
columns = {
{ heading = '#', key = 'idx', width = 2 },
{ heading = 'T', key = 'ftype', width = 1 },
{ heading = 'Input', key = 'input' },
{ heading = 'Output', key = 'output', width = 12 },
{ heading = 'Fuel', key = 'fuel', width = 10 },
{ heading = 'State', key = 'fstate', width = 5 },
},
values = {},
},
},
-- Smelt recipe tab
smeltTab = UI.Tab {
index = 2,
title = "Smelt",
noFill = true,
backgroundColor = colors.black,
enableAllBtn = UI.Button {
x = -18, y = 0,
text = "All On",
backgroundColor = colors.green,
backgroundFocusColor = colors.lime,
textColor = colors.white,
event = 'enable_all',
},
disableAllBtn = UI.Button {
x = -9, y = 0,
text = "All Off",
backgroundColor = colors.red,
backgroundFocusColor = colors.orange,
textColor = colors.white,
event = 'disable_all',
},
grid = UI.ScrollingGrid {
y = 1, ey = -1,
disableHeader = false,
headerBackgroundColor = colors.gray,
headerTextColor = colors.lightGray,
backgroundColor = colors.black,
alternateRowColor = colors.gray,
textColor = colors.white,
sortColumn = 'inputShort',
columns = {
{ heading = 'Input', key = 'inputShort' },
{ heading = 'Output', key = 'resultShort', width = 12 },
{ heading = 'Type', key = 'types', width = 3 },
{ heading = 'Stock', key = 'inStorage', width = 6 },
{ heading = 'On?', key = 'toggleLabel', width = 4 },
},
values = {},
getRowTextColor = function(self, row, selected)
if selected then return colors.white end
return colors.white
end,
-- Single-click triggers grid_select (for monitor touch)
eventHandler = function(self, event)
if event.type == 'mouse_click' then
local handled = UI.Grid.eventHandler(self, event)
if handled and self.selected then
self:emit({ type = 'grid_select', selected = self.selected, element = self })
end
return handled
end
return UI.Grid.eventHandler(self, event)
end,
},
},
-- Craft tab
craftTab = UI.Tab {
index = 3,
title = "Craft",
noFill = true,
backgroundColor = colors.black,
turtleStatus = UI.Window {
x = -14, y = 0, width = 14, height = 1,
draw = function(self)
local turtleOk = ctx.craftTurtleOk
or (ctx.craftTurtleName
and peripheral.isPresent(ctx.craftTurtleName))
local label = turtleOk and " Turtle OK " or " No Turtle "
local bg = turtleOk and colors.lime or colors.red
local fg = turtleOk and colors.black or colors.white
self:clear(colors.black)
self:write(1, 1, label, bg, fg)
end,
},
grid = UI.ScrollingGrid {
y = 1, ey = -1,
disableHeader = false,
headerBackgroundColor = colors.gray,
headerTextColor = colors.lightGray,
backgroundColor = colors.black,
alternateRowColor = colors.gray,
textColor = colors.white,
sortColumn = 'short',
columns = {
{ heading = '#', key = 'dispIdx', width = 3 },
{ heading = 'Output', key = 'short' },
{ heading = 'Yield', key = 'yield', width = 5 },
{ heading = 'Can Make', key = 'batches', width = 8 },
{ heading = 'Go', key = 'goLabel', width = 6 },
},
values = {},
-- Single-click triggers grid_select (for monitor touch)
eventHandler = function(self, event)
if event.type == 'mouse_click' then
local handled = UI.Grid.eventHandler(self, event)
if handled and self.selected then
self:emit({ type = 'grid_select', selected = self.selected, element = self })
end
return handled
end
return UI.Grid.eventHandler(self, event)
end,
},
},
-- Missing tab
missingTab = UI.Tab {
index = 4,
title = "Missing",
noFill = true,
backgroundColor = colors.black,
grid = UI.ScrollingGrid {
y = 1, ey = -1,
disableHeader = false,
headerBackgroundColor = colors.gray,
headerTextColor = colors.lightGray,
backgroundColor = colors.black,
alternateRowColor = colors.gray,
textColor = colors.white,
sortColumn = 'short',
columns = {
{ heading = '#', key = 'dispIdx', width = 3 },
{ heading = 'Output', key = 'short' },
{ heading = 'Missing (have/need)', key = 'summary' },
},
values = {},
getRowTextColor = function(self, row, selected)
if selected then return colors.white end
return colors.red
end,
},
},
},
-- Footer info bar
smelterFooter = UI.Window {
x = 1, ey = -2, ex = -1, height = 1,
backgroundColor = colors.gray,
draw = function(self)
self:clear(colors.gray)
if smelterView == "status" or smelterView == "smelt" then
local enabledCount = 0
local totalRecipes = 0
for _ in pairs(cfg.SMELTABLE) do totalRecipes = totalRecipes + 1 end
for inputName in pairs(cfg.SMELTABLE) do
if not state.disabledRecipes[inputName] then
enabledCount = enabledCount + 1
end
end
local info = string.format(" Smelt: %d/%d enabled", enabledCount, totalRecipes)
self:write(2, 1, info, colors.gray, colors.white)
if activity.smelting then
self:write(2 + #info + 2, 1, " SMELTING... ", colors.orange, colors.white)
end
elseif smelterView == "craft" then
self:write(2, 1, " Select recipe to craft", colors.gray, colors.white)
if activity.crafting then
self:write(26, 1, " CRAFTING... ", colors.orange, colors.white)
end
elseif smelterView == "missing" then
local availCount = 0
for _, recipe in ipairs(cfg.CRAFTABLE) do
if ops.canCraftRecipe(recipe) then availCount = availCount + 1 end
end
local info = string.format(" Available: %d/%d recipes",
availCount, #cfg.CRAFTABLE)
self:write(2, 1, info, colors.gray, colors.white)
end
end,
},
-- Bottom accent
bottomBar = UI.Window {
x = 1, ey = -1, ex = -1, height = 1,
backgroundColor = colors.purple,
draw = function(self)
self:clear(colors.purple)
local msg = " Smelt recipe manager "
if smelterView == "status" then
msg = activity.smelting and " SMELTING... " or " Furnace status "
elseif smelterView == "smelt" then
msg = " Tap recipe to toggle "
elseif smelterView == "craft" then
msg = activity.crafting and " CRAFTING... " or " Tap to craft "
elseif smelterView == "missing" then
msg = " Missing ingredients "
end
self:centeredWrite(1, msg, colors.purple, colors.pink)
end,
},
notification = UI.Notification {
anchor = 'bottom',
},
eventHandler = function(self, event)
if event.type == 'enable_all' then
state.disabledRecipes = {}
log.debug("UI", "All recipes enabled")
ops.saveDisabledRecipes()
state.smelterNeedsRedraw = true
return true
elseif event.type == 'disable_all' then
for inputName in pairs(cfg.SMELTABLE) do
state.disabledRecipes[inputName] = true
end
log.debug("UI", "All recipes disabled")
ops.saveDisabledRecipes()
state.smelterNeedsRedraw = true
return true
elseif event.type == 'grid_select' then
if smelterView == "smelt" and event.selected then
local inputName = event.selected.inputName
if inputName then
if state.disabledRecipes[inputName] then
state.disabledRecipes[inputName] = nil
else
state.disabledRecipes[inputName] = true
end
local short = shortName(inputName)
log.debug("UI", "Recipe %s: %s", short,
state.disabledRecipes[inputName] and "OFF" or "ON")
ops.saveDisabledRecipes()
state.smelterNeedsRedraw = true
end
return true
elseif smelterView == "craft" and event.selected then
local recipeIdx = event.selected.idx
local recipe = cfg.CRAFTABLE[recipeIdx]
if recipe then
-- Re-scan for turtle if none known
if not ctx.craftTurtleName then
for _, pName in ipairs(peripheral.getNames()) do
if pName:match("^turtle_") then
ctx.craftTurtleName = pName
log.info("CRAFT", "Turtle found on re-scan: %s", pName)
break
end
end
end
local turtleOk = ctx.craftTurtleOk
or (ctx.craftTurtleName
and peripheral.isPresent(ctx.craftTurtleName))
if not turtleOk then
log.warn("CRAFT", "No crafting turtle! (name=%s)",
tostring(ctx.craftTurtleName))
self.notification:error("No crafting turtle!")
return true
end
local short = shortName(recipe.output)
log.info("CRAFT", "Craft: %s (#%d)", short, recipeIdx)
local ok, err = ops.craftItem(recipeIdx)
if ok then
self.notification:success("Crafted " .. short .. " x" .. recipe.count)
state.statusMessage = "Crafted " .. short
state.statusColor = colors.lime
else
self.notification:error("Failed: " .. (err or "unknown"))
state.statusMessage = "Craft failed"
state.statusColor = colors.red
end
state.statusTimer = 5
state.needsRedraw = true
state.smelterNeedsRedraw = true
end
return true
end
end
return UI.Page.eventHandler(self, event)
end,
}
-- Make status row clickable for pause toggle
smelterPage.statusRow.focus = function() end
-- Attach to device
smelterDevice.currentPage = smelterPage
smelterPage.parent = smelterDevice
smelterPage:resize()
smelterPage:setParent()
smelterPage:enable()
end
-------------------------------------------------
-- Data refresh functions
-------------------------------------------------
function D.refreshSmelterData()
if not smelterPage then return end
-- Status tab
local furnaceList = cache.furnaceStatus or {}
local statusValues = {}
for i, fs in ipairs(furnaceList) do
local typeAbbr = "F"
if fs.type == "minecraft:smoker" then typeAbbr = "S"
elseif fs.type == "minecraft:blast_furnace" then typeAbbr = "B"
end
local inputStr = "(empty)"
if fs.input then
local n = shortName(fs.input.name)
inputStr = n .. " x" .. fs.input.count
end
local outputStr = "-"
if fs.output then
local n = shortName(fs.output.name)
outputStr = n .. " x" .. fs.output.count
end
local fuelStr = "-"
if fs.fuel then
local n = shortName(fs.fuel.name)
fuelStr = n .. " x" .. fs.fuel.count
end
local stateStr = " IDLE"
if state.smeltingPaused then stateStr = "PAUSE"
elseif fs.active then stateStr = " COOK"
elseif fs.input and not fs.fuel then stateStr = "FUEL?"
end
table.insert(statusValues, {
idx = tostring(i),
ftype = typeAbbr,
input = inputStr,
output = outputStr,
fuel = fuelStr,
fstate = stateStr,
})
end
smelterPage.tabs.statusTab.grid:setValues(statusValues)
-- Smelt tab: build stock lookup from itemList (works for both manager and client)
state.ensureItemList()
local stockLookup = {}
for _, item in ipairs(cache.itemList or {}) do
stockLookup[item.name] = item.total
end
local recipeList = {}
for inputName, recipe in pairs(cfg.SMELTABLE) do
local short = shortName(inputName)
local resultShort = shortName(recipe.result)
local types = ""
local fSet = recipe.furnaceSet or {}
if fSet["minecraft:furnace"] then types = types .. "F" end
if fSet["minecraft:smoker"] then types = types .. "S" end
if fSet["minecraft:blast_furnace"] then types = types .. "B" end
local enabled = not state.disabledRecipes[inputName]
local inStorage = stockLookup[inputName] or 0
table.insert(recipeList, {
inputName = inputName,
inputShort = short,
resultShort = resultShort,
types = types,
inStorage = tostring(inStorage),
toggleLabel = enabled and " ON " or " OFF",
enabled = enabled,
})
end
table.sort(recipeList, function(a, b) return a.inputShort < b.inputShort end)
smelterPage.tabs.smeltTab.grid:setValues(recipeList)
-- Craft tab
local availList = {}
for idx, recipe in ipairs(cfg.CRAFTABLE) do
if ops.canCraftRecipe(recipe) then
local short = shortName(recipe.output)
local batches = ops.maxCraftBatches(recipe)
table.insert(availList, {
idx = idx,
dispIdx = tostring(#availList + 1),
short = short,
yield = "x" .. recipe.count,
batches = "x" .. batches,
goLabel = " MAKE ",
})
end
end
smelterPage.tabs.craftTab.grid:setValues(availList)
-- Missing tab
local missList = {}
for idx, recipe in ipairs(cfg.CRAFTABLE) do
if not ops.canCraftRecipe(recipe) then
local short = shortName(recipe.output)
local missing = ops.getMissingIngredients(recipe)
local parts = {}
for _, m in ipairs(missing) do
local mShort = shortName(m.name)
table.insert(parts, string.format("%s %d/%d", mShort, m.have, m.need))
end
table.insert(missList, {
idx = idx,
dispIdx = tostring(#missList + 1),
short = short .. " x" .. recipe.count,
summary = table.concat(parts, ", "),
})
end
end
smelterPage.tabs.missingTab.grid:setValues(missList)
end
-------------------------------------------------
-- Draw functions (called by inventoryManager tasks)
-------------------------------------------------
function D.drawDashboard()
if not mainPage then
if mainDevice then
buildMainPage()
end
if not mainPage then return end
end
-- Update dynamic data
D.refreshItemGrid()
-- Update refresh button state
if activity.scanning then
mainPage.refreshBtn.text = "Scanning"
mainPage.refreshBtn.backgroundColor = colors.yellow
mainPage.refreshBtn.textColor = colors.black
else
mainPage.refreshBtn.text = "Refresh"
mainPage.refreshBtn.backgroundColor = colors.green
mainPage.refreshBtn.textColor = colors.white
end
-- Draw everything
mainPage:draw()
mainDevice:sync()
end
function D.drawSmelterDashboard()
if not smelterPage then
if smelterDevice then
buildSmelterPage()
end
if not smelterPage then return end
end
D.refreshSmelterData()
smelterPage:draw()
smelterDevice:sync()
end
-------------------------------------------------
-- Touch handlers (compatibility bridge)
-- Route monitor_touch events through Opus UI
-------------------------------------------------
function D.handleTouch(x, y)
if not mainPage or not mainDevice then return end
local clickEvent = mainPage:pointToChild(x, y)
if clickEvent and clickEvent.element then
clickEvent.type = 'mouse_click'
clickEvent.key = 'mouse_click'
clickEvent.button = 1
clickEvent.ie = { code = 'mouse_click', x = clickEvent.x, y = clickEvent.y }
if clickEvent.element.focus then
mainPage:setFocus(clickEvent.element)
end
clickEvent.element:emit(clickEvent)
mainPage:sync()
end
end
function D.handleSmelterTouch(x, y)
if not smelterPage or not smelterDevice then return end
local clickEvent = smelterPage:pointToChild(x, y)
if clickEvent and clickEvent.element then
clickEvent.type = 'mouse_click'
clickEvent.key = 'mouse_click'
clickEvent.button = 1
clickEvent.ie = { code = 'mouse_click', x = clickEvent.x, y = clickEvent.y }
if clickEvent.element.focus then
smelterPage:setFocus(clickEvent.element)
end
clickEvent.element:emit(clickEvent)
smelterPage:sync()
end
end
-------------------------------------------------
-- Billboard rendering (raw monitor API, no Opus UI)
-- Visual goals display with pie chart, storage
-- gauge, stock alerts, and activity indicators.
-------------------------------------------------
local BB = {} -- billboard color theme
BB.bg = colors.black
BB.headerBg = colors.blue
BB.headerFg = colors.white
BB.border = colors.gray
BB.label = colors.lightGray
BB.value = colors.white
BB.barFull = colors.lime
BB.barEmpty = colors.gray
BB.barWarn = colors.yellow
BB.barCrit = colors.red
BB.alertOk = colors.lime
BB.alertLow = colors.red
BB.alertWarn = colors.orange
BB.activityOn = colors.lime
BB.activityOff = colors.gray
BB.sectionHead = colors.yellow
-- Pie chart slice colors (12 distinct CC colors)
local PIE_COLORS = {
colors.red,
colors.orange,
colors.yellow,
colors.lime,
colors.green,
colors.cyan,
colors.lightBlue,
colors.blue,
colors.purple,
colors.magenta,
colors.pink,
colors.brown,
}
local function bbFormatNumber(n)
if n >= 1000000 then
return string.format("%.1fM", n / 1000000)
elseif n >= 10000 then
return string.format("%.1fK", n / 1000)
elseif n >= 1000 then
return string.format("%d,%03d", math.floor(n / 1000), n % 1000)
end
return tostring(n)
end
local function bbPadRight(s, w)
if #s >= w then return s:sub(1, w) end
return s .. string.rep(" ", w - #s)
end
local function bbPadLeft(s, w)
if #s >= w then return s:sub(1, w) end
return string.rep(" ", w - #s) .. s
end
-- Billboard drawing primitives (operate on D.billboardMon)
local bbW, bbH = 0, 0
local function bbSetColors(fg, bg)
D.billboardMon.setTextColor(fg)
D.billboardMon.setBackgroundColor(bg)
end
local function bbClearLine(y, bg)
D.billboardMon.setCursorPos(1, y)
D.billboardMon.setBackgroundColor(bg or BB.bg)
D.billboardMon.write(string.rep(" ", bbW))
end
local function bbWriteAt(x, y, text, fg, bg)
bbSetColors(fg or BB.value, bg or BB.bg)
D.billboardMon.setCursorPos(x, y)
D.billboardMon.write(text)
end
local function bbHLine(y, fg)
bbClearLine(y)
bbSetColors(fg or BB.border, BB.bg)
D.billboardMon.setCursorPos(1, y)
D.billboardMon.write(string.rep("\x8c", bbW))
end
local function bbDrawBar(x, y, width, filled, fgColor, bgColor)
local fillW = math.floor(filled * width + 0.5)
if fillW > width then fillW = width end
if fillW < 0 then fillW = 0 end
D.billboardMon.setCursorPos(x, y)
D.billboardMon.setBackgroundColor(fgColor or BB.barFull)
D.billboardMon.write(string.rep(" ", fillW))
D.billboardMon.setBackgroundColor(bgColor or BB.barEmpty)
D.billboardMon.write(string.rep(" ", width - fillW))
D.billboardMon.setBackgroundColor(BB.bg)
end
--- Draw a pie chart using colored character cells.
--- slices = { { fraction=0.0-1.0, color=colors.X }, ... }
--- Draws into the rectangle (x1,y1) to (x1+size-1, y1+rows-1)
--- size = width in chars, rows = height in chars
local function bbDrawPie(x1, y1, size, rows, slices)
local cx = size / 2 -- center in char coords
local cy = rows / 2
local radius = math.min(cx, cy) - 0.5
-- Character cells are ~1.5x taller than wide; squish Y
local aspect = 1.5
-- Build cumulative angle boundaries
local angles = {}
local cumulative = 0
for i, slice in ipairs(slices) do
angles[i] = { start = cumulative, stop = cumulative + slice.fraction, color = slice.color }
cumulative = cumulative + slice.fraction
end
for row = 0, rows - 1 do
D.billboardMon.setCursorPos(x1, y1 + row)
for col = 0, size - 1 do
local dx = (col + 0.5 - cx)
local dy = (row + 0.5 - cy) * aspect
local dist = math.sqrt(dx * dx + dy * dy)
if dist <= radius then
-- Compute angle 0-1 (0 = top, clockwise)
local angle = math.atan2(dx, -dy) -- top = 0, clockwise
if angle < 0 then angle = angle + 2 * math.pi end
local frac = angle / (2 * math.pi)
-- Find which slice
local cellColor = BB.bg
for _, s in ipairs(angles) do
if frac >= s.start and frac < s.stop then
cellColor = s.color
break
end
end
-- Catch rounding at the very end
if cellColor == BB.bg and #angles > 0 then
cellColor = angles[#angles].color
end
D.billboardMon.setBackgroundColor(cellColor)
D.billboardMon.write(" ")
else
D.billboardMon.setBackgroundColor(BB.bg)
D.billboardMon.write(" ")
end
end
end
D.billboardMon.setBackgroundColor(BB.bg)
end
--- Draw a storage ring/donut gauge.
--- Draws a ring showing used vs free with percentage in center.
local function bbDrawStorageRing(x1, y1, size, rows)
local cx = size / 2
local cy = rows / 2
local outerR = math.min(cx, cy) - 0.5
local innerR = outerR * 0.55
local aspect = 1.5
local ratio = cache.usedRatio or 0
local usedColor = BB.barFull
if ratio > 0.9 then usedColor = BB.barCrit
elseif ratio > 0.75 then usedColor = BB.barWarn end
for row = 0, rows - 1 do
D.billboardMon.setCursorPos(x1, y1 + row)
for col = 0, size - 1 do
local dx = (col + 0.5 - cx)
local dy = (row + 0.5 - cy) * aspect
local dist = math.sqrt(dx * dx + dy * dy)
if dist <= outerR and dist >= innerR then
-- In the ring — determine angle (top = 0, clockwise)
local angle = math.atan2(dx, -dy)
if angle < 0 then angle = angle + 2 * math.pi end
local frac = angle / (2 * math.pi)
if frac < ratio then
D.billboardMon.setBackgroundColor(usedColor)
else
D.billboardMon.setBackgroundColor(BB.barEmpty)
end
D.billboardMon.write(" ")
else
D.billboardMon.setBackgroundColor(BB.bg)
D.billboardMon.write(" ")
end
end
end
-- Write percentage in center of ring
local pct = tostring(math.floor(ratio * 100 + 0.5)) .. "%"
local textY = y1 + math.floor(cy)
local textX = x1 + math.floor(cx - #pct / 2)
bbWriteAt(textX, textY, pct, usedColor)
end
-- Billboard section: header
local function bbDrawHeader(y)
bbClearLine(y, BB.headerBg)
bbSetColors(BB.headerFg, BB.headerBg)
local title = " INVENTORY BILLBOARD "
D.billboardMon.setCursorPos(math.floor((bbW - #title) / 2) + 1, y)
D.billboardMon.write(title)
return y + 1
end
-- Billboard section: storage ring + stats (side by side)
local function bbDrawStorageSection(y)
bbHLine(y)
y = y + 1
-- Ring takes up a square area
local ringSize = math.min(math.floor(bbW * 0.35), bbH - y - 6)
if ringSize < 5 then ringSize = 5 end
local ringRows = math.floor(ringSize / 1.5) -- aspect correction
if ringRows < 3 then ringRows = 3 end
-- Draw ring on the left
bbDrawStorageRing(2, y, ringSize, ringRows)
-- Stats text to the right of the ring
local statsX = ringSize + 4
local statsY = y + 1
bbWriteAt(statsX, statsY, "STORAGE", BB.sectionHead)
statsY = statsY + 1
local ratio = cache.usedRatio or 0
local usedColor = BB.barFull
if ratio > 0.9 then usedColor = BB.barCrit
elseif ratio > 0.75 then usedColor = BB.barWarn end
bbWriteAt(statsX, statsY, string.format("%s / %s slots",
bbFormatNumber(cache.usedSlots), bbFormatNumber(cache.totalSlots)), BB.value)
statsY = statsY + 1
bbWriteAt(statsX, statsY, string.format("%s total items",
bbFormatNumber(cache.grandTotal)), BB.label)
statsY = statsY + 1
bbWriteAt(statsX, statsY, string.format("%d chests", cache.chestCount), BB.label)
statsY = statsY + 1
-- Mini capacity bar
local barW = bbW - statsX - 1
if barW > 3 then
bbWriteAt(statsX, statsY, "", BB.label)
bbDrawBar(statsX, statsY, barW, ratio, usedColor, BB.barEmpty)
end
return y + ringRows + 1
end
-- Billboard section: pie chart + legend
local function bbDrawPieSection(y, maxH)
bbHLine(y)
y = y + 1
bbWriteAt(2, y, "ITEM DISTRIBUTION", BB.sectionHead)
y = y + 1
state.ensureItemList()
local items = cache.itemList
if not items or #items == 0 then
bbWriteAt(2, y, "No items in storage", BB.label)
return y + 1
end
-- Sort and pick top N for pie slices
local sorted = {}
for i, item in ipairs(items) do sorted[i] = item end
table.sort(sorted, function(a, b) return a.total > b.total end)
local maxSlices = math.min(#PIE_COLORS, cfg.BILLBOARD_TOP_ITEMS or 12, #sorted)
local topTotal = 0
for i = 1, maxSlices do
topTotal = topTotal + sorted[i].total
end
-- "Other" bucket for remaining items
local otherTotal = (cache.grandTotal or 0) - topTotal
local total = cache.grandTotal or 1
if total < 1 then total = 1 end
-- Build slices
local slices = {}
local legendItems = {}
for i = 1, maxSlices do
local frac = sorted[i].total / total
if frac < 0.005 then break end -- skip tiny slices
table.insert(slices, { fraction = frac, color = PIE_COLORS[i] })
table.insert(legendItems, {
name = shortName(sorted[i].name),
count = sorted[i].total,
pct = math.floor(frac * 100 + 0.5),
color = PIE_COLORS[i],
})
end
if otherTotal > 0 then
table.insert(slices, { fraction = otherTotal / total, color = BB.border })
table.insert(legendItems, {
name = "Other",
count = otherTotal,
pct = math.floor(otherTotal / total * 100 + 0.5),
color = BB.border,
})
end
-- Layout: pie on left, legend on right
local pieSize = math.min(math.floor(bbW * 0.4), maxH - 1)
if pieSize < 5 then pieSize = 5 end
local pieRows = math.floor(pieSize / 1.5)
if pieRows < 3 then pieRows = 3 end
if pieRows > maxH - 1 then pieRows = maxH - 1 end
-- Draw pie
if #slices > 0 then
bbDrawPie(2, y, pieSize, pieRows, slices)
end
-- Draw legend to the right
local legX = pieSize + 4
local legY = y
local legW = bbW - legX - 1
for i, item in ipairs(legendItems) do
if legY >= y + pieRows then break end
if legW < 10 then break end
-- Color swatch
D.billboardMon.setCursorPos(legX, legY)
D.billboardMon.setBackgroundColor(item.color)
D.billboardMon.write(" ")
D.billboardMon.setBackgroundColor(BB.bg)
D.billboardMon.write(" ")
-- Name + count
local label = item.name
local countStr = bbFormatNumber(item.count)
local pctStr = item.pct .. "%"
local infoW = legW - 4 -- 2 swatch + 1 space + padding
local detail = string.format("%s %s", pctStr, countStr)
local nameW = infoW - #detail - 1
if nameW < 4 then nameW = 4 end
if #label > nameW then label = label:sub(1, nameW - 1) .. "." end
bbWriteAt(legX + 3, legY, bbPadRight(label, nameW), BB.value)
bbWriteAt(legX + 3 + nameW + 1, legY, detail, BB.label)
legY = legY + 1
end
return y + pieRows
end
-- Billboard section: stock alerts (compact)
local function bbDrawAlerts(y, maxRows)
bbHLine(y)
y = y + 1
local alerts = state.activeAlerts
if not alerts or #alerts == 0 then
bbWriteAt(2, y, "* All stocks OK", BB.alertOk)
return y + 1
end
bbWriteAt(2, y, "ALERTS", BB.sectionHead)
local countStr = string.format("(%d)", #alerts)
bbWriteAt(9, y, countStr, BB.alertWarn)
y = y + 1
local colW = math.floor(bbW / 2)
local twoCol = (bbW >= 30)
local row = 0
for i, alert in ipairs(alerts) do
if row >= maxRows then
bbWriteAt(2, y, string.format(" +%d more", #alerts - i + 1), BB.alertWarn)
y = y + 1
break
end
local label = alert.label or shortName(alert.name or "?")
local current = alert.current or 0
local minVal = alert.min or 0
local ratio = minVal > 0 and (current / minVal) or 1
local color = ratio < 0.5 and BB.alertLow or BB.alertWarn
local text = string.format("! %s %s/%s", label, bbFormatNumber(current), bbFormatNumber(minVal))
if twoCol then
local col = ((i - 1) % 2 == 0) and 2 or (colW + 1)
if col == 2 then bbClearLine(y) end
bbWriteAt(col, y, bbPadRight(text, colW - 1), color)
if (i - 1) % 2 == 1 or i == #alerts then
y = y + 1
row = row + 1
end
else
bbClearLine(y)
bbWriteAt(2, y, text, color)
y = y + 1
row = row + 1
end
end
return y
end
-- Billboard section: activity bar (single line)
local function bbDrawActivityBar(y)
bbClearLine(y, BB.headerBg)
bbSetColors(BB.headerFg, BB.headerBg)
local labels = {
{ key = "sorting", label = "SORT" },
{ key = "scanning", label = "SCAN" },
{ key = "smelting", label = "SMLT" },
{ key = "dispensing", label = "DISP" },
{ key = "defragging", label = "DEFR" },
{ key = "composting", label = "COMP" },
{ key = "crafting", label = "CRFT" },
{ key = "autocrafting", label = "AUTO" },
{ key = "discarding", label = "DISC" },
}
local parts = {}
for _, entry in ipairs(labels) do
if activity[entry.key] then
table.insert(parts, entry.label)
end
end
local text
if #parts > 0 then
text = " " .. table.concat(parts, " | ") .. " "
else
text = " IDLE "
end
D.billboardMon.setCursorPos(math.floor((bbW - #text) / 2) + 1, y)
if #parts > 0 then
bbSetColors(colors.white, colors.green)
else
bbSetColors(BB.label, BB.headerBg)
end
D.billboardMon.write(text)
return y + 1
end
-- Main billboard draw entry point
function D.drawBillboard()
if not D.billboardMon then return end
bbW, bbH = D.billboardMon.getSize()
D.billboardMon.setBackgroundColor(BB.bg)
D.billboardMon.clear()
local y = 1
-- Header bar
y = bbDrawHeader(y)
-- Storage ring + stats
y = bbDrawStorageSection(y)
-- Pie chart + legend (gets remaining space minus alerts + footer)
local alertCount = state.activeAlerts and #state.activeAlerts or 0
local alertH = math.max(2, math.min(5, math.ceil(alertCount / 2) + 2))
local footerH = 1 -- activity bar
local pieH = bbH - y - alertH - footerH
if pieH < 5 then pieH = 5 end
y = bbDrawPieSection(y, pieH)
-- Alerts
local alertMaxRows = bbH - y - footerH - 1
if alertMaxRows < 1 then alertMaxRows = 1 end
y = bbDrawAlerts(y, alertMaxRows)
-- Fill gap before footer
while y < bbH do
bbClearLine(y)
y = y + 1
end
-- Activity bar at very bottom
bbDrawActivityBar(bbH)
end
return D
end