1970 lines
72 KiB
Lua
1970 lines
72 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
|
|
-------------------------------------------------
|
|
|
|
--- Check whether two peripheral names refer to the same physical block.
|
|
-- Needed because a monitor placed adjacent to the computer AND connected
|
|
-- via wired modem appears under both its side name (e.g. "left") and its
|
|
-- network name (e.g. "monitor_0"). A simple string comparison misses this.
|
|
local function sameMonitor(a, b)
|
|
if not a or not b then return false end
|
|
if a == b then return true end
|
|
-- Write a distinctive cursor position via one name and read it back
|
|
-- via the other; if they share state, they are the same peripheral.
|
|
local ok1 = pcall(peripheral.call, a, "setCursorPos", 9876, 5432)
|
|
if not ok1 then return false end
|
|
local ok2, x, y = pcall(peripheral.call, b, "getCursorPos")
|
|
pcall(peripheral.call, a, "setCursorPos", 1, 1)
|
|
return ok2 and x == 9876 and y == 5432
|
|
end
|
|
|
|
local function findMonitor(side, excludeNames)
|
|
excludeNames = excludeNames or {}
|
|
local mon = peripheral.wrap(side)
|
|
local monName
|
|
if mon and mon.setTextScale then
|
|
monName = side
|
|
else
|
|
mon = nil
|
|
end
|
|
if not mon then
|
|
for _, name in ipairs(peripheral.getNames()) do
|
|
if peripheral.getType(name) == "monitor" then
|
|
local dominated = false
|
|
for _, ex in ipairs(excludeNames) do
|
|
if sameMonitor(name, ex) then dominated = true; break end
|
|
end
|
|
if not dominated then
|
|
mon = peripheral.wrap(name)
|
|
monName = name
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return mon, monName
|
|
end
|
|
|
|
function D.setupMonitor()
|
|
D.mon, D.monName = findMonitor(cfg.MONITOR_SIDE, { cfg.SMELTER_MONITOR_SIDE })
|
|
if not D.mon then return false end
|
|
|
|
mainDevice = UI.Device({
|
|
device = D.mon,
|
|
textScale = 0.5,
|
|
})
|
|
return true
|
|
end
|
|
|
|
function D.setupSmelterMonitor()
|
|
D.smelterMon, D.smelterMonName = findMonitor(cfg.SMELTER_MONITOR_SIDE, { D.monName })
|
|
if not D.smelterMon then return false end
|
|
|
|
smelterDevice = UI.Device({
|
|
device = D.smelterMon,
|
|
textScale = 0.5,
|
|
})
|
|
return true
|
|
end
|
|
|
|
function D.setupBillboardMonitor()
|
|
-- If explicitly configured, use that name
|
|
local scale = cfg.BILLBOARD_TEXT_SCALE or 1
|
|
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)
|
|
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 sameMonitor(name, D.monName)
|
|
and not sameMonitor(name, D.smelterMonName) then
|
|
local mon = peripheral.wrap(name)
|
|
if mon and mon.setTextScale then
|
|
D.billboardMon = mon
|
|
D.billboardMonName = name
|
|
D.billboardMon.setTextScale(scale)
|
|
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 |