From d97167b21cd2d4748a29850061d42cf0c8cd10ac Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sun, 22 Mar 2026 20:15:53 -0400 Subject: [PATCH] Add client_display.lua for network client dashboard using Opus UI - Implemented a client dashboard mirroring manager/display.lua - Integrated state management via master broadcasts (ctx.cache, ctx.activity) - Enabled action commands to master through ctx.sendToMaster() - Established UI components for inventory management and smelting operations - Included item filtering, stock tracking, and crafting capabilities - Designed responsive layouts for main and smelter dashboards --- manager/client_display.lua | 1270 ++++++++++++++++++++++++++++++++++++ 1 file changed, 1270 insertions(+) create mode 100644 manager/client_display.lua diff --git a/manager/client_display.lua b/manager/client_display.lua new file mode 100644 index 0000000..25a8f73 --- /dev/null +++ b/manager/client_display.lua @@ -0,0 +1,1270 @@ +-- 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