diff --git a/manager/client_display.lua b/manager/client_display.lua deleted file mode 100644 index 25a8f73..0000000 --- a/manager/client_display.lua +++ /dev/null @@ -1,1270 +0,0 @@ --- 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 diff --git a/manager/display.lua b/manager/display.lua index bd7ea5d..f8423f7 100644 --- a/manager/display.lua +++ b/manager/display.lua @@ -958,8 +958,9 @@ local function buildSmelterPage() turtleStatus = UI.Window { x = -14, y = 0, width = 14, height = 1, draw = function(self) - local turtleOk = ctx.craftTurtleName - and peripheral.isPresent(ctx.craftTurtleName) + 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 @@ -1140,8 +1141,9 @@ local function buildSmelterPage() local recipeIdx = event.selected.idx local recipe = cfg.CRAFTABLE[recipeIdx] if recipe then - local turtleOk = ctx.craftTurtleName - and peripheral.isPresent(ctx.craftTurtleName) + local turtleOk = ctx.craftTurtleOk + or (ctx.craftTurtleName + and peripheral.isPresent(ctx.craftTurtleName)) if not turtleOk then self.notification:error("No crafting turtle!") return true