-- 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