-- manager/client_display.lua — Client dashboard rendering using Opus UI framework -- Usage: local display = dofile("manager/client_display.lua")(ctx) -- -- Mirrors manager/display.lua but adapted for the network client: -- - State is received from master broadcasts (ctx.cache, ctx.activity, etc.) -- - Actions send commands to master via ctx.sendToMaster() -- - No direct access to operations or config modules -- -- Requires Opus UI framework (opus.ui) return function(ctx) local UI = require('opus.ui') local log = ctx.log local D = {} ------------------------------------------------- -- Monitor handles ------------------------------------------------- D.mon = nil D.monName = nil D.smelterMon = nil D.smelterMonName = 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 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() local filtered = {} for _, item in ipairs(ctx.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 ctx.activity.sorting then table.insert(parts, "SORTING") end if ctx.activity.dispensing then table.insert(parts, "DISPENSING") end if ctx.activity.smelting then table.insert(parts, "SMELTING") end if ctx.activity.scanning then table.insert(parts, "SCANNING") end if ctx.activity.defragging then table.insert(parts, "DEFRAG") end if ctx.activity.composting then table.insert(parts, "COMPOST") end if #parts > 0 then return table.concat(parts, " | ") end return "" end local function getBottomMessage() if ctx.activity.dispensing then return "DISPENSING..." elseif ctx.activity.smelting then return "SMELTING..." elseif ctx.activity.sorting then return "SORTING BARREL..." elseif ctx.activity.defragging then return "DEFRAGMENTING..." elseif ctx.activity.composting then return "COMPOSTING..." end return "Tap item to order" end local function getStorageBarColor() if ctx.cache.usedRatio > 0.9 then return colors.red elseif ctx.cache.usedRatio > 0.7 then return colors.orange elseif ctx.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 --- Get stock total for an item from itemList local function getItemTotal(itemName) for _, item in ipairs(ctx.cache.itemList) do if item.name == itemName then return item.total end end return 0 end local function getRecipeIngredients(recipe) local ingredients = {} for _, item in ipairs(recipe.grid) do if item then ingredients[item] = (ingredients[item] or 0) + 1 end end return ingredients end local function canCraftRecipe(recipe) local ingredients = getRecipeIngredients(recipe) for itemName, needed in pairs(ingredients) do if (getItemTotal(itemName) or 0) < needed then return false end end return true end local function maxCraftBatches(recipe) local ingredients = getRecipeIngredients(recipe) local minBatches = math.huge for itemName, needed in pairs(ingredients) do local batches = math.floor((getItemTotal(itemName) or 0) / needed) if batches < minBatches then minBatches = batches end end if minBatches == math.huge then return 0 end return minBatches end local function getMissingIngredients(recipe) local ingredients = getRecipeIngredients(recipe) local missing = {} for itemName, needed in pairs(ingredients) do local have = getItemTotal(itemName) or 0 if have < needed then table.insert(missing, { name = itemName, have = have, need = needed }) end end return missing 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(ctx.monitorSide, ctx.smelterMonitorSide) 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(ctx.smelterMonitorSide, D.monName) if not D.smelterMon then return false end smelterDevice = UI.Device({ device = D.smelterMon, textScale = 0.5, }) return true 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", ctx.cache.chestCount)) table.insert(parts, ctx.cache.dropperOk and "Dropper: OK" or "Dropper: --") table.insert(parts, ctx.cache.barrelOk and "Barrel: OK" or "Barrel: --") if ctx.cache.furnaceCount and ctx.cache.furnaceCount > 0 then table.insert(parts, string.format("Furnaces: %d", ctx.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)", ctx.cache.usedSlots, ctx.cache.totalSlots, ctx.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 = ctx.cache.usedRatio or 0 local filled = math.floor(ratio * barWidth) local barColor = getStorageBarColor() 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 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) 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) local kbLabel = showKeyboard and " X " or " ? " local kbBg = showKeyboard and colors.red or colors.purple self:write(1, 1, kbLabel, kbBg, colors.white) 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, 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 #ctx.activeAlerts > 0 then local alertIdx = math.floor(os.epoch("utc") / 2000) % #ctx.activeAlerts + 1 local a = ctx.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 ctx.statusTimer > 0 and #ctx.statusMessage > 0 then self:centeredWrite(1, ctx.statusMessage, colors.black, ctx.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) local footerLeft = string.format(" Total: %d items | %d types ", ctx.cache.grandTotal, #ctx.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, 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 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 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 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 local short = shortName(row.name) ctx.statusMessage = string.format("Ordering %s x%d...", short, selectedAmount) ctx.statusColor = colors.cyan ctx.statusTimer = 10 ctx.needsRedraw = true -- Send order to master instead of calling ops directly ctx.sendToMaster({ type = "order", itemName = row.name, amount = selectedAmount, dropperName = ctx.clientDropperName ~= "" and ctx.clientDropperName or nil, }) log.info("ORDER", "Sent to master: %s x%d", row.name, selectedAmount) end return true elseif event.type == 'amount_select' then selectedAmount = event.button.amount D.updateAmountButtons() self:sync() return true elseif event.type == 'do_scan' then ctx.statusMessage = "Requesting refresh..." ctx.statusColor = colors.cyan ctx.statusTimer = 3 ctx.needsRedraw = true ctx.sendToMaster({ type = "scan" }) log.debug("UI", "Scan request sent to master") 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 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() 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 = 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(ctx.cache.furnaceStatus or {}) do if fs.active then activeCount = activeCount + 1 end end local statusStr = string.format(" Furnaces: %d Active: %d", ctx.cache.furnaceCount or 0, activeCount) self:write(2, 1, statusStr, colors.gray, colors.white) local pauseLabel = ctx.smeltingPaused and " PAUSED " or " ACTIVE " local pauseBg = ctx.smeltingPaused and colors.red or colors.lime local pauseFg = ctx.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 = ctx.smeltingPaused and " PAUSED " or " ACTIVE " local pauseStart = self.width - #pauseLabel + 1 if event.x >= pauseStart then ctx.sendToMaster({ type = "toggle_pause" }) log.debug("UI", "Toggle pause sent to master") ctx.smelterNeedsRedraw = 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, -- 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, 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 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 = {}, 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(ctx.SMELTABLE) do totalRecipes = totalRecipes + 1 end for inputName in pairs(ctx.SMELTABLE) do if not ctx.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 ctx.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 ctx.activity.crafting then self:write(26, 1, " CRAFTING... ", colors.orange, colors.white) end elseif smelterView == "missing" then local availCount = 0 for _, recipe in ipairs(ctx.CRAFTABLE) do if canCraftRecipe(recipe) then availCount = availCount + 1 end end local info = string.format(" Available: %d/%d recipes", availCount, #ctx.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 = ctx.activity.smelting and " SMELTING... " or " Furnace status " elseif smelterView == "smelt" then msg = " Tap recipe to toggle " elseif smelterView == "craft" then msg = ctx.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 == 'tab_change' then local tabMap = { 'status', 'smelt', 'craft', 'missing' } if event.current then smelterView = tabMap[event.current] or smelterView end D.refreshSmelterData() self.smelterFooter:draw() self.bottomBar:draw() elseif event.type == 'enable_all' then ctx.sendToMaster({ type = "enable_all" }) log.debug("UI", "Enable all sent to master") ctx.smelterNeedsRedraw = true return true elseif event.type == 'disable_all' then ctx.sendToMaster({ type = "disable_all" }) log.debug("UI", "Disable all sent to master") ctx.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 ctx.sendToMaster({ type = "toggle_recipe", recipe = inputName }) log.debug("UI", "Toggle recipe sent: %s", inputName) ctx.smelterNeedsRedraw = true end return true elseif smelterView == "craft" and event.selected then local recipeIdx = event.selected.idx local recipe = ctx.CRAFTABLE[recipeIdx] if recipe then if not ctx.craftTurtleOk then self.notification:error("No crafting turtle!") return true end local short = shortName(recipe.output) log.info("CRAFT", "Craft request sent: %s", short) ctx.sendToMaster({ type = "craft", recipeIdx = recipeIdx }) ctx.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 = ctx.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 ctx.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 local recipeList = {} for inputName, recipe in pairs(ctx.SMELTABLE) do local short = shortName(inputName) local resultShort = shortName(recipe.result) local types = "" if recipe.furnaces then for _, ft in ipairs(recipe.furnaces) do if ft == "minecraft:furnace" then types = types .. "F" elseif ft == "minecraft:smoker" then types = types .. "S" elseif ft == "minecraft:blast_furnace" then types = types .. "B" end end elseif recipe.furnaceSet then if recipe.furnaceSet["minecraft:furnace"] then types = types .. "F" end if recipe.furnaceSet["minecraft:smoker"] then types = types .. "S" end if recipe.furnaceSet["minecraft:blast_furnace"] then types = types .. "B" end end local enabled = not ctx.disabledRecipes[inputName] local inStorage = 0 for _, item in ipairs(ctx.cache.itemList) do if item.name == inputName then inStorage = item.total break end end 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(ctx.CRAFTABLE) do if canCraftRecipe(recipe) then local short = shortName(recipe.output) local batches = 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(ctx.CRAFTABLE) do if not canCraftRecipe(recipe) then local short = shortName(recipe.output) local missing = 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 inventoryClient tasks) ------------------------------------------------- function D.drawDashboard() if not ctx.connected then -- Show waiting screen using Opus UI if not mainPage then if mainDevice then buildMainPage() end end if mainPage then -- Override with waiting screen mainPage:draw() mainDevice:sync() end return end if not mainPage then if mainDevice then buildMainPage() end if not mainPage then return end end D.refreshItemGrid() if ctx.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 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 — route monitor_touch 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 return D end