-- 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 ------------------------------------------------- local function findMonitor(side, excludeSide) 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" and name ~= excludeSide then mon = peripheral.wrap(name) monName = name break 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 not cfg.BILLBOARD_MONITOR_SIDE or cfg.BILLBOARD_MONITOR_SIDE == "" then return false end local mon = peripheral.wrap(cfg.BILLBOARD_MONITOR_SIDE) if mon and mon.setTextScale then D.billboardMon = mon D.billboardMonName = cfg.BILLBOARD_MONITOR_SIDE D.billboardMon.setTextScale(0.5) return true end -- Fallback: try to find a monitor with that name on the network for _, name in ipairs(peripheral.getNames()) do if name == cfg.BILLBOARD_MONITOR_SIDE and peripheral.getType(name) == "monitor" then D.billboardMon = peripheral.wrap(name) D.billboardMonName = name D.billboardMon.setTextScale(0.5) return true 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) -- Read-only goals display: storage bar, top items -- chart, stock alerts, 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.graphBar = colors.cyan BB.graphBarAlt = colors.lightBlue BB.sectionHead = colors.yellow 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 bbWriteCentered(y, text, fg, bg) bbClearLine(y, bg or BB.bg) bbSetColors(fg or BB.value, bg or BB.bg) D.billboardMon.setCursorPos(math.floor((bbW - #text) / 2) + 1, y) D.billboardMon.write(text) 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, char, fg, bg) bbClearLine(y, bg or BB.bg) bbSetColors(fg or BB.border, bg or BB.bg) D.billboardMon.setCursorPos(1, y) D.billboardMon.write(string.rep(char or "-", 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 -- Billboard section: header local function bbDrawHeader(y) bbClearLine(y, BB.headerBg) bbSetColors(BB.headerFg, BB.headerBg) local title = " INVENTORY GOALS BILLBOARD " D.billboardMon.setCursorPos(math.floor((bbW - #title) / 2) + 1, y) D.billboardMon.write(title) return y + 1 end -- Billboard section: storage capacity local function bbDrawStorage(y) bbHLine(y) y = y + 1 bbWriteAt(2, y, "> STORAGE", BB.sectionHead) y = y + 1 local ratio = cache.usedRatio or 0 local pct = math.floor(ratio * 100 + 0.5) local barColor = BB.barFull if ratio > 0.9 then barColor = BB.barCrit elseif ratio > 0.75 then barColor = BB.barWarn end local barW = bbW - 10 if barW < 10 then barW = 10 end bbWriteAt(2, y, bbPadLeft(pct .. "%", 4), barColor) bbDrawBar(7, y, barW, ratio, barColor, BB.barEmpty) y = y + 1 local stats = string.format( "Slots: %s/%s | Items: %s | Chests: %d", bbFormatNumber(cache.usedSlots), bbFormatNumber(cache.totalSlots), bbFormatNumber(cache.grandTotal), cache.chestCount ) bbWriteAt(2, y, stats, BB.label) y = y + 1 return y end -- Billboard section: top items bar chart local function bbDrawItems(y, maxRows) bbHLine(y) y = y + 1 bbWriteAt(2, y, "> TOP ITEMS", 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 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 count = math.min(#sorted, maxRows, cfg.BILLBOARD_TOP_ITEMS or 20) if count < 1 then count = 1 end local maxVal = sorted[1].total if maxVal < 1 then maxVal = 1 end local numW = 8 local nameW = math.floor(bbW * 0.35) if nameW < 12 then nameW = 12 end if nameW > 25 then nameW = 25 end local barW = bbW - nameW - numW - 5 if barW < 5 then barW = 5 end for i = 1, count do local item = sorted[i] local name = shortName(item.name) local num = bbFormatNumber(item.total) local frac = item.total / maxVal bbClearLine(y) bbWriteAt(1, y, bbPadLeft(tostring(i), 2), BB.border) bbWriteAt(4, y, bbPadRight(name, nameW), BB.value) local barColor = (i % 2 == 0) and BB.graphBarAlt or BB.graphBar local barStart = 4 + nameW + 1 bbDrawBar(barStart, y, barW, frac, barColor, BB.barEmpty) bbWriteAt(barStart + barW + 1, y, bbPadLeft(num, numW), BB.value) y = y + 1 end return y end -- Billboard section: stock alerts local function bbDrawAlerts(y, maxRows) bbHLine(y) y = y + 1 bbWriteAt(2, y, "> STOCK ALERTS", BB.sectionHead) 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 local colW = math.floor(bbW / 2) local twoCol = (bbW >= 40) 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 icon, color if ratio < 0.5 then icon, color = "!", BB.alertLow else icon, color = "!", BB.alertWarn end local text = string.format("%s %s: %s/%s", icon, 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 footer local function bbDrawActivity(y) bbHLine(y) y = y + 1 bbClearLine(y) bbWriteAt(2, y, "ACTIVITY:", BB.sectionHead) local labels = { { key = "sorting", label = "SORT" }, { key = "scanning", label = "SCAN" }, { key = "smelting", label = "SMELT" }, { key = "dispensing", label = "DISPENSE" }, { key = "defragging", label = "DEFRAG" }, { key = "composting", label = "COMPOST" }, { key = "crafting", label = "CRAFT" }, { key = "autocrafting", label = "AUTOCRAFT" }, { key = "discarding", label = "DISCARD" }, } local x = 12 local anyActive = false for _, entry in ipairs(labels) do if activity[entry.key] then anyActive = true if x + #entry.label + 2 > bbW then y = y + 1 bbClearLine(y) x = 3 end bbWriteAt(x, y, entry.label, BB.activityOn) x = x + #entry.label + 2 end end if not anyActive then bbWriteAt(12, y, "IDLE", BB.activityOff) end 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 y = bbDrawHeader(y) y = bbDrawStorage(y) -- Allocate vertical space: alerts ~3-8 lines, footer ~2 lines local alertLines = state.activeAlerts and #state.activeAlerts or 0 local alertSectionH = math.max(3, math.min(6, math.ceil(alertLines / 2) + 2)) local footerH = 2 local itemRows = bbH - y - alertSectionH - footerH if itemRows < 3 then itemRows = 3 end y = bbDrawItems(y, itemRows) local alertMaxRows = bbH - y - footerH if alertMaxRows < 2 then alertMaxRows = 2 end y = bbDrawAlerts(y, alertMaxRows) -- Fill gap before footer local footerY = bbH - 1 while y < footerY do bbClearLine(y) y = y + 1 end bbDrawActivity(footerY) end return D end