Files
Inventory-Manager-CC/manager/display.lua
MayaTheShy 5518161adf fix: call enable() on pages after attaching to device
Opus Canvas only renders children with enabled=true. Without
calling page:enable(), all widgets were skipped during render,
resulting in a blank black monitor.
2026-03-22 19:04:20 -04:00

1101 lines
40 KiB
Lua

-- manager/display.lua — Dashboard rendering using Opus UI framework
-- Usage: local display = dofile("manager/display.lua")(ctx)
--
-- Requires Opus UI framework (opus.ui, opus.event)
-- Falls back to raw monitor drawing if Opus is not available.
return function(ctx)
local UI = require('opus.ui')
local cfg = ctx.cfg
local state = ctx.state
local log = ctx.log
local ops = ctx.ops
local cache = state.cache
local activity = state.activity
local D = {}
-------------------------------------------------
-- Monitor handles
-------------------------------------------------
D.mon = nil
D.monName = nil
D.smelterMon = nil
D.smelterMonName = nil
-- 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 smelterView = "status"
-------------------------------------------------
-- Helpers
-------------------------------------------------
local function shortName(fullName)
local s = fullName:gsub("^minecraft:", ""):gsub("_", " ")
return s:sub(1,1):upper() .. s:sub(2)
end
local function getFilteredItems()
state.ensureItemList()
local filtered = {}
for _, item in ipairs(cache.itemList) do
if searchQuery == "" then
table.insert(filtered, item)
else
local lower = item.name:lower():gsub("minecraft:", ""):gsub("_", " ")
if lower:find(searchQuery:lower(), 1, true) then
table.insert(filtered, item)
end
end
end
return filtered
end
local function getActivityString()
local parts = {}
if activity.sorting then table.insert(parts, "SORTING") end
if activity.dispensing then table.insert(parts, "DISPENSING") end
if activity.smelting then table.insert(parts, "SMELTING") end
if activity.scanning then table.insert(parts, "SCANNING") end
if activity.defragging then table.insert(parts, "DEFRAG") end
if activity.composting then table.insert(parts, "COMPOST") end
if #parts > 0 then
return table.concat(parts, " | ")
end
return ""
end
local function getBottomMessage()
if activity.dispensing then return "DISPENSING..."
elseif activity.smelting then return "SMELTING..."
elseif activity.sorting then return "SORTING BARREL..."
elseif activity.defragging then return "DEFRAGMENTING..."
elseif activity.composting then return "COMPOSTING..."
end
return "Tap item to order"
end
local function getStorageBarColor()
if cache.usedRatio > 0.9 then return colors.red
elseif cache.usedRatio > 0.7 then return colors.orange
elseif cache.usedRatio > 0.5 then return colors.yellow
end
return colors.lime
end
local function getStockBarColor(_, ratio)
if ratio < 0.25 then return colors.red
elseif ratio < 0.5 then return colors.orange
end
return colors.lime
end
-------------------------------------------------
-- Monitor setup
-------------------------------------------------
local function findMonitor(side, excludeSide)
local mon = peripheral.wrap(side)
local monName
if mon and mon.setTextScale then
monName = side
else
mon = nil
end
if not mon then
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "monitor" and name ~= excludeSide then
mon = peripheral.wrap(name)
monName = name
break
end
end
end
return mon, monName
end
function D.setupMonitor()
D.mon, D.monName = findMonitor(cfg.MONITOR_SIDE, cfg.SMELTER_MONITOR_SIDE)
if not D.mon then return false end
mainDevice = UI.Device({
device = D.mon,
textScale = 0.5,
})
return true
end
function D.setupSmelterMonitor()
D.smelterMon, D.smelterMonName = findMonitor(cfg.SMELTER_MONITOR_SIDE, D.monName)
if not D.smelterMon then return false end
smelterDevice = UI.Device({
device = D.smelterMon,
textScale = 0.5,
})
return true
end
-------------------------------------------------
-- Build main dashboard page
-------------------------------------------------
local function buildMainPage()
if not mainDevice then return end
mainPage = UI.Page {
backgroundColor = colors.black,
textColor = colors.white,
-- Title bar (row 1)
titleBar = UI.Window {
x = 1, y = 1, ex = -1, height = 1,
backgroundColor = colors.blue,
draw = function(self)
self:clear(colors.blue)
self:centeredWrite(1, " ** INVENTORY MANAGER ** ", colors.blue, colors.white)
end,
},
-- Status bar (row 2)
statusRow = UI.Window {
x = 1, y = 2, ex = -1, height = 1,
backgroundColor = colors.gray,
draw = function(self)
self:clear(colors.gray)
local parts = {}
table.insert(parts, string.format(" Chests: %d", cache.chestCount))
table.insert(parts, cache.dropperOk and "Dropper: OK" or "Dropper: --")
table.insert(parts, cache.barrelOk and "Barrel: OK" or "Barrel: --")
if cache.furnaceCount and cache.furnaceCount > 0 then
table.insert(parts, string.format("Furnaces: %d", cache.furnaceCount))
end
self:write(2, 1, table.concat(parts, " | "), colors.gray, colors.white)
local actStr = getActivityString()
if #actStr > 0 then
actStr = " " .. actStr .. " "
self:write(self.width - #actStr + 1, 1, actStr, colors.orange, colors.white)
end
end,
},
-- Divider (row 3)
divider = UI.Window {
x = 1, y = 3, ex = -1, height = 1,
backgroundColor = colors.lightBlue,
draw = function(self)
self:clear(colors.lightBlue)
local dash = string.rep("-", math.min(self.width - 4, 60))
self:centeredWrite(1, dash, colors.lightBlue, colors.cyan)
end,
},
-- Storage label + bar (row 4)
storageRow = UI.Window {
x = 1, y = 4, ex = -1, height = 1,
backgroundColor = colors.black,
draw = function(self)
self:clear(colors.black)
local label = string.format(" Storage: %d/%d slots (%d free)",
cache.usedSlots, cache.totalSlots, cache.freeSlots)
self:write(2, 1, label, colors.black, colors.lightGray)
local barStart = #label + 4
local barWidth = self.width - barStart - 2
if barWidth > 4 then
local ratio = cache.usedRatio or 0
local filled = math.floor(ratio * barWidth)
local barColor = getStorageBarColor()
-- Draw the bar
if filled > 0 then
self:write(barStart, 1, string.rep(" ", filled), barColor)
end
if barWidth - filled > 0 then
self:write(barStart + filled, 1,
string.rep(" ", barWidth - filled), colors.gray)
end
-- Overlay percentage text
local pctStr = string.format(" %d%% ", math.floor(ratio * 100))
local pctX = barStart + math.floor(barWidth / 2) - math.floor(#pctStr / 2)
for ci = 1, #pctStr do
local cx = pctX + ci - 1
if cx >= barStart and cx < barStart + barWidth then
local bg = (cx - barStart) < filled and barColor or colors.gray
self:write(cx, 1, pctStr:sub(ci, ci), bg, colors.white)
end
end
end
end,
},
-- Amount selector row (row 5)
amountRow = UI.Window {
x = 1, y = 5, ex = -1, height = 1,
backgroundColor = colors.black,
draw = function(self)
self:clear(colors.black)
self:write(2, 1, "Qty:", colors.black, colors.lightGray)
-- Amount buttons are drawn as children
self:drawChildren()
end,
},
-- Search + refresh row (row 6)
searchRow = UI.Window {
x = 1, y = 6, ex = -1, height = 1,
backgroundColor = colors.black,
},
searchEntry = UI.TextEntry {
x = 3, y = 6,
ex = '45%',
shadowText = "search...",
backgroundColor = colors.black,
backgroundFocusColor = colors.gray,
textColor = colors.white,
shadowTextColor = colors.gray,
limit = 30,
},
refreshBtn = UI.Button {
y = 5, ex = -2,
text = "Refresh",
backgroundColor = colors.green,
backgroundFocusColor = colors.lime,
textColor = colors.white,
event = 'do_scan',
},
-- Item grid (rows 7 to h-3)
itemGrid = UI.ScrollingGrid {
x = 1, y = 7, ex = -1, ey = -4,
disableHeader = false,
headerHeight = 1,
headerBackgroundColor = colors.gray,
headerTextColor = colors.lightGray,
backgroundColor = colors.black,
alternateRowColor = colors.gray,
backgroundSelectedColor = colors.blue,
unfocusedBackgroundSelectedColor = colors.blue,
textColor = colors.white,
focusIndicator = '>',
sortColumn = 'total',
inverseSort = true,
columns = {
{ heading = '#', key = 'idx', width = 3 },
{ heading = 'Item', key = 'short' },
{ heading = 'Qty', key = 'qty', width = 6, textColor = colors.yellow },
{ heading = 'Stock', key = 'ratio', width = 12,
barColumn = true,
barColor = getStockBarColor,
barEmptyColor = colors.gray,
},
},
values = {},
getDisplayValues = function(_, row)
return {
idx = tostring(row.idx or ''),
short = row.short or '',
qty = tostring(row.total or 0),
ratio = row.ratio or 0,
}
end,
-- Single-click triggers grid_select (for monitor touch)
eventHandler = function(self, event)
if event.type == 'mouse_click' then
local handled = UI.Grid.eventHandler(self, event)
if handled and self.selected then
self:emit({ type = 'grid_select', selected = self.selected, element = self })
end
return handled
end
return UI.Grid.eventHandler(self, event)
end,
},
-- Alert / status area
alertBar = UI.Window {
x = 1, ey = -3, ex = -1, height = 1,
backgroundColor = colors.black,
draw = function(self)
self:clear(colors.black)
if #state.activeAlerts > 0 then
local alertIdx = math.floor(os.epoch("utc") / 2000) % #state.activeAlerts + 1
local a = state.activeAlerts[alertIdx]
local msg = string.format(" LOW STOCK: %s (%d/%d) ", a.label, a.current, a.min)
self:centeredWrite(1, msg, colors.red, colors.white)
elseif state.statusTimer > 0 and #state.statusMessage > 0 then
self:centeredWrite(1, state.statusMessage, colors.black, state.statusColor)
end
end,
},
-- Footer
footerBar = UI.Window {
x = 1, ey = -2, ex = -1, height = 1,
backgroundColor = colors.gray,
draw = function(self)
self:clear(colors.gray)
state.ensureItemList()
local footerLeft = string.format(" Total: %d items | %d types ",
cache.grandTotal, #cache.itemList)
self:write(2, 1, footerLeft, colors.gray, colors.white)
if searchQuery ~= "" then
local filteredItems = getFilteredItems()
local filterNote = string.format("| Showing %d ", #filteredItems)
self:write(2 + #footerLeft + 1, 1, filterNote, colors.gray, colors.yellow)
end
local timeStr = textutils.formatTime(os.time(), true)
self:write(self.width - #timeStr - 1, 1, timeStr, colors.gray, colors.lightGray)
end,
},
-- Bottom accent
bottomBar = UI.Window {
x = 1, ey = -1, ex = -1, height = 1,
backgroundColor = colors.blue,
draw = function(self)
self:clear(colors.blue)
self:centeredWrite(1, " " .. getBottomMessage() .. " ", colors.blue, colors.lightBlue)
end,
},
-- Notification overlay
notification = UI.Notification {
anchor = 'bottom',
},
eventHandler = function(self, event)
if event.type == 'text_change' then
searchQuery = event.text or ""
D.refreshItemGrid()
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)
state.statusMessage = string.format("Ordering %s x%d...", short, selectedAmount)
state.statusColor = colors.cyan
state.statusTimer = 10
activity.dispensing = true
state.needsRedraw = true
ops.orderItem(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
state.statusMessage = "Refreshing..."
state.statusColor = colors.cyan
state.statusTimer = 3
state.needsRedraw = true
return true
end
return UI.Page.eventHandler(self, event)
end,
}
-- Add amount buttons as children of amountRow
local btnX = 7
for _, amt in ipairs(amountOptions) do
local uid = 'amt_' .. amt
mainPage.amountRow[uid] = UI.Button {
x = btnX, y = 1,
text = tostring(amt),
backgroundColor = (amt == selectedAmount) and colors.cyan or colors.gray,
backgroundFocusColor = colors.cyan,
textColor = (amt == selectedAmount) and colors.white or colors.lightGray,
textFocusColor = colors.white,
event = 'amount_select',
amount = amt,
}
btnX = btnX + #tostring(amt) + 4
end
-- Attach to device
mainDevice.currentPage = mainPage
mainPage.parent = mainDevice
mainPage:setParent()
mainPage:enable()
end
function D.updateAmountButtons()
if not mainPage then return end
for _, amt in ipairs(amountOptions) do
local btn = mainPage.amountRow['amt_' .. amt]
if btn then
btn.backgroundColor = (amt == selectedAmount) and colors.cyan or colors.gray
btn.textColor = (amt == selectedAmount) and colors.white or colors.lightGray
btn:draw()
end
end
end
function D.refreshItemGrid()
if not mainPage then return end
local filteredItems = getFilteredItems()
-- Find max count for ratio calc
local maxCount = 0
for _, item in ipairs(filteredItems) do
if item.total > maxCount then maxCount = item.total end
end
if maxCount == 0 then maxCount = 1 end
local gridValues = {}
for i, item in ipairs(filteredItems) do
table.insert(gridValues, {
idx = i,
name = item.name,
short = shortName(item.name),
total = item.total,
qty = tostring(item.total),
ratio = item.total / maxCount,
})
end
mainPage.itemGrid:setValues(gridValues)
end
-------------------------------------------------
-- Build smelter dashboard page
-------------------------------------------------
local function buildSmelterPage()
if not smelterDevice then return end
smelterPage = UI.Page {
backgroundColor = colors.black,
textColor = colors.white,
-- Title bar
titleBar = UI.Window {
x = 1, y = 1, ex = -1, height = 1,
backgroundColor = colors.purple,
draw = function(self)
self:clear(colors.purple)
self:centeredWrite(1, " ** SMELTER DASHBOARD ** ", colors.purple, colors.white)
end,
},
-- Status bar with pause toggle
statusRow = UI.Window {
x = 1, y = 2, ex = -1, height = 1,
backgroundColor = colors.gray,
draw = function(self)
self:clear(colors.gray)
local activeCount = 0
for _, fs in ipairs(cache.furnaceStatus or {}) do
if fs.active then activeCount = activeCount + 1 end
end
local statusStr = string.format(" Furnaces: %d Active: %d",
cache.furnaceCount or 0, activeCount)
self:write(2, 1, statusStr, colors.gray, colors.white)
local pauseLabel = state.smeltingPaused and " PAUSED " or " ACTIVE "
local pauseBg = state.smeltingPaused and colors.red or colors.lime
local pauseFg = state.smeltingPaused and colors.white or colors.black
self:write(self.width - #pauseLabel + 1, 1, pauseLabel, pauseBg, pauseFg)
end,
eventHandler = function(self, event)
if event.type == 'mouse_click' then
local pauseLabel = state.smeltingPaused and " PAUSED " or " ACTIVE "
local pauseStart = self.width - #pauseLabel + 1
if event.x >= pauseStart then
state.smeltingPaused = not state.smeltingPaused
log.debug("UI", "Smelting %s",
state.smeltingPaused and "PAUSED" or "RESUMED")
ops.saveDisabledRecipes()
state.smelterNeedsRedraw = true
state.needsRedraw = true
return true
end
end
return UI.Window.eventHandler(self, event)
end,
},
-- Divider
divider = UI.Window {
x = 1, y = 3, ex = -1, height = 1,
backgroundColor = colors.magenta,
draw = function(self)
self:clear(colors.magenta)
local dash = string.rep("-", math.min(self.width - 4, 60))
self:centeredWrite(1, dash, colors.magenta, colors.pink)
end,
},
-- Tabs container
tabs = UI.Tabs {
x = 1, y = 4, ex = -1, ey = -3,
barBackgroundColor = colors.black,
selectedBackgroundColor = colors.purple,
unselectedBackgroundColor = colors.gray,
-- Status tab
statusTab = UI.Tab {
index = 1,
title = "Status",
noFill = true,
backgroundColor = colors.black,
grid = UI.ScrollingGrid {
y = 1, ey = -1,
disableHeader = false,
headerBackgroundColor = colors.gray,
headerTextColor = colors.lightGray,
backgroundColor = colors.black,
alternateRowColor = colors.gray,
textColor = colors.white,
sortColumn = 'idx',
columns = {
{ heading = '#', key = 'idx', width = 2 },
{ heading = 'T', key = 'ftype', width = 1 },
{ heading = 'Input', key = 'input' },
{ heading = 'Output', key = 'output', width = 12 },
{ heading = 'Fuel', key = 'fuel', width = 10 },
{ heading = 'State', key = 'fstate', width = 5 },
},
values = {},
},
},
-- Smelt recipe tab
smeltTab = UI.Tab {
index = 2,
title = "Smelt",
noFill = true,
backgroundColor = colors.black,
enableAllBtn = UI.Button {
x = -18, y = 0,
text = "All On",
backgroundColor = colors.green,
backgroundFocusColor = colors.lime,
textColor = colors.white,
event = 'enable_all',
},
disableAllBtn = UI.Button {
x = -9, y = 0,
text = "All Off",
backgroundColor = colors.red,
backgroundFocusColor = colors.orange,
textColor = colors.white,
event = 'disable_all',
},
grid = UI.ScrollingGrid {
y = 1, ey = -1,
disableHeader = false,
headerBackgroundColor = colors.gray,
headerTextColor = colors.lightGray,
backgroundColor = colors.black,
alternateRowColor = colors.gray,
textColor = colors.white,
sortColumn = 'inputShort',
columns = {
{ heading = 'Input', key = 'inputShort' },
{ heading = 'Output', key = 'resultShort', width = 12 },
{ heading = 'Type', key = 'types', width = 3 },
{ heading = 'Stock', key = 'inStorage', width = 6 },
{ heading = 'On?', key = 'toggleLabel', width = 4 },
},
values = {},
getRowTextColor = function(self, row, selected)
if selected then return colors.white end
return colors.white
end,
-- Single-click triggers grid_select (for monitor touch)
eventHandler = function(self, event)
if event.type == 'mouse_click' then
local handled = UI.Grid.eventHandler(self, event)
if handled and self.selected then
self:emit({ type = 'grid_select', selected = self.selected, element = self })
end
return handled
end
return UI.Grid.eventHandler(self, event)
end,
},
},
-- Craft tab
craftTab = UI.Tab {
index = 3,
title = "Craft",
noFill = true,
backgroundColor = colors.black,
turtleStatus = UI.Window {
x = -14, y = 0, width = 14, height = 1,
draw = function(self)
local turtleOk = ctx.craftTurtleName
and peripheral.isPresent(ctx.craftTurtleName)
local label = turtleOk and " Turtle OK " or " No Turtle "
local bg = turtleOk and colors.lime or colors.red
local fg = turtleOk and colors.black or colors.white
self:clear(colors.black)
self:write(1, 1, label, bg, fg)
end,
},
grid = UI.ScrollingGrid {
y = 1, ey = -1,
disableHeader = false,
headerBackgroundColor = colors.gray,
headerTextColor = colors.lightGray,
backgroundColor = colors.black,
alternateRowColor = colors.gray,
textColor = colors.white,
sortColumn = 'short',
columns = {
{ heading = '#', key = 'dispIdx', width = 3 },
{ heading = 'Output', key = 'short' },
{ heading = 'Yield', key = 'yield', width = 5 },
{ heading = 'Can Make', key = 'batches', width = 8 },
{ heading = 'Go', key = 'goLabel', width = 6 },
},
values = {},
-- Single-click triggers grid_select (for monitor touch)
eventHandler = function(self, event)
if event.type == 'mouse_click' then
local handled = UI.Grid.eventHandler(self, event)
if handled and self.selected then
self:emit({ type = 'grid_select', selected = self.selected, element = self })
end
return handled
end
return UI.Grid.eventHandler(self, event)
end,
},
},
-- Missing tab
missingTab = UI.Tab {
index = 4,
title = "Missing",
noFill = true,
backgroundColor = colors.black,
grid = UI.ScrollingGrid {
y = 1, ey = -1,
disableHeader = false,
headerBackgroundColor = colors.gray,
headerTextColor = colors.lightGray,
backgroundColor = colors.black,
alternateRowColor = colors.gray,
textColor = colors.white,
sortColumn = 'short',
columns = {
{ heading = '#', key = 'dispIdx', width = 3 },
{ heading = 'Output', key = 'short' },
{ heading = 'Missing (have/need)', key = 'summary' },
},
values = {},
getRowTextColor = function(self, row, selected)
if selected then return colors.white end
return colors.red
end,
},
},
},
-- Footer info bar
smelterFooter = UI.Window {
x = 1, ey = -2, ex = -1, height = 1,
backgroundColor = colors.gray,
draw = function(self)
self:clear(colors.gray)
if smelterView == "status" or smelterView == "smelt" then
local enabledCount = 0
local totalRecipes = 0
for _ in pairs(cfg.SMELTABLE) do totalRecipes = totalRecipes + 1 end
for inputName in pairs(cfg.SMELTABLE) do
if not state.disabledRecipes[inputName] then
enabledCount = enabledCount + 1
end
end
local info = string.format(" Smelt: %d/%d enabled", enabledCount, totalRecipes)
self:write(2, 1, info, colors.gray, colors.white)
if activity.smelting then
self:write(2 + #info + 2, 1, " SMELTING... ", colors.orange, colors.white)
end
elseif smelterView == "craft" then
self:write(2, 1, " Select recipe to craft", colors.gray, colors.white)
if activity.crafting then
self:write(26, 1, " CRAFTING... ", colors.orange, colors.white)
end
elseif smelterView == "missing" then
local availCount = 0
for _, recipe in ipairs(cfg.CRAFTABLE) do
if ops.canCraftRecipe(recipe) then availCount = availCount + 1 end
end
local info = string.format(" Available: %d/%d recipes",
availCount, #cfg.CRAFTABLE)
self:write(2, 1, info, colors.gray, colors.white)
end
end,
},
-- Bottom accent
bottomBar = UI.Window {
x = 1, ey = -1, ex = -1, height = 1,
backgroundColor = colors.purple,
draw = function(self)
self:clear(colors.purple)
local msg = " Smelt recipe manager "
if smelterView == "status" then
msg = activity.smelting and " SMELTING... " or " Furnace status "
elseif smelterView == "smelt" then
msg = " Tap recipe to toggle "
elseif smelterView == "craft" then
msg = activity.crafting and " CRAFTING... " or " Tap to craft "
elseif smelterView == "missing" then
msg = " Missing ingredients "
end
self:centeredWrite(1, msg, colors.purple, colors.pink)
end,
},
notification = UI.Notification {
anchor = 'bottom',
},
eventHandler = function(self, event)
if event.type == '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()
-- fall through to default handler for tab switching
elseif event.type == 'enable_all' then
state.disabledRecipes = {}
log.debug("UI", "All recipes enabled")
ops.saveDisabledRecipes()
state.smelterNeedsRedraw = true
return true
elseif event.type == 'disable_all' then
for inputName in pairs(cfg.SMELTABLE) do
state.disabledRecipes[inputName] = true
end
log.debug("UI", "All recipes disabled")
ops.saveDisabledRecipes()
state.smelterNeedsRedraw = true
return true
elseif event.type == 'grid_select' then
if smelterView == "smelt" and event.selected then
local inputName = event.selected.inputName
if inputName then
if state.disabledRecipes[inputName] then
state.disabledRecipes[inputName] = nil
else
state.disabledRecipes[inputName] = true
end
local short = shortName(inputName)
log.debug("UI", "Recipe %s: %s", short,
state.disabledRecipes[inputName] and "OFF" or "ON")
ops.saveDisabledRecipes()
state.smelterNeedsRedraw = true
end
return true
elseif smelterView == "craft" and event.selected then
local recipeIdx = event.selected.idx
local recipe = cfg.CRAFTABLE[recipeIdx]
if recipe then
local turtleOk = ctx.craftTurtleName
and peripheral.isPresent(ctx.craftTurtleName)
if not turtleOk then
self.notification:error("No crafting turtle!")
return true
end
local short = shortName(recipe.output)
log.info("CRAFT", "Craft: %s (#%d)", short, recipeIdx)
local ok, err = ops.craftItem(recipeIdx)
if ok then
self.notification:success("Crafted " .. short .. " x" .. recipe.count)
state.statusMessage = "Crafted " .. short
state.statusColor = colors.lime
else
self.notification:error("Failed: " .. (err or "unknown"))
state.statusMessage = "Craft failed"
state.statusColor = colors.red
end
state.statusTimer = 5
state.needsRedraw = true
state.smelterNeedsRedraw = true
end
return true
end
end
return UI.Page.eventHandler(self, event)
end,
}
-- Make status row clickable for pause toggle
smelterPage.statusRow.focus = function() end
-- Attach to device
smelterDevice.currentPage = smelterPage
smelterPage.parent = smelterDevice
smelterPage:setParent()
smelterPage:enable()
end
-------------------------------------------------
-- Data refresh functions
-------------------------------------------------
function D.refreshSmelterData()
if not smelterPage then return end
-- Status tab
local furnaceList = cache.furnaceStatus or {}
local statusValues = {}
for i, fs in ipairs(furnaceList) do
local typeAbbr = "F"
if fs.type == "minecraft:smoker" then typeAbbr = "S"
elseif fs.type == "minecraft:blast_furnace" then typeAbbr = "B"
end
local inputStr = "(empty)"
if fs.input then
local n = shortName(fs.input.name)
inputStr = n .. " x" .. fs.input.count
end
local outputStr = "-"
if fs.output then
local n = shortName(fs.output.name)
outputStr = n .. " x" .. fs.output.count
end
local fuelStr = "-"
if fs.fuel then
local n = shortName(fs.fuel.name)
fuelStr = n .. " x" .. fs.fuel.count
end
local stateStr = " IDLE"
if state.smeltingPaused then stateStr = "PAUSE"
elseif fs.active then stateStr = " COOK"
elseif fs.input and not fs.fuel then stateStr = "FUEL?"
end
table.insert(statusValues, {
idx = tostring(i),
ftype = typeAbbr,
input = inputStr,
output = outputStr,
fuel = fuelStr,
fstate = stateStr,
})
end
smelterPage.tabs.statusTab.grid:setValues(statusValues)
-- Smelt tab
local recipeList = {}
for inputName, recipe in pairs(cfg.SMELTABLE) do
local short = shortName(inputName)
local resultShort = shortName(recipe.result)
local types = ""
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
local enabled = not state.disabledRecipes[inputName]
local inStorage = 0
if cache.catalogue[inputName] then
for _, s in ipairs(cache.catalogue[inputName]) do
inStorage = inStorage + s.total
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(cfg.CRAFTABLE) do
if ops.canCraftRecipe(recipe) then
local short = shortName(recipe.output)
local batches = ops.maxCraftBatches(recipe)
table.insert(availList, {
idx = idx,
dispIdx = tostring(#availList + 1),
short = short,
yield = "x" .. recipe.count,
batches = "x" .. batches,
goLabel = " MAKE ",
})
end
end
smelterPage.tabs.craftTab.grid:setValues(availList)
-- Missing tab
local missList = {}
for idx, recipe in ipairs(cfg.CRAFTABLE) do
if not ops.canCraftRecipe(recipe) then
local short = shortName(recipe.output)
local missing = ops.getMissingIngredients(recipe)
local parts = {}
for _, m in ipairs(missing) do
local mShort = shortName(m.name)
table.insert(parts, string.format("%s %d/%d", mShort, m.have, m.need))
end
table.insert(missList, {
idx = idx,
dispIdx = tostring(#missList + 1),
short = short .. " x" .. recipe.count,
summary = table.concat(parts, ", "),
})
end
end
smelterPage.tabs.missingTab.grid:setValues(missList)
end
-------------------------------------------------
-- Draw functions (called by inventoryManager tasks)
-------------------------------------------------
function D.drawDashboard()
if not mainPage then
if mainDevice then
buildMainPage()
end
if not mainPage then return end
end
-- Update dynamic data
D.refreshItemGrid()
-- Update refresh button state
if activity.scanning then
mainPage.refreshBtn.text = "Scanning"
mainPage.refreshBtn.backgroundColor = colors.yellow
mainPage.refreshBtn.textColor = colors.black
else
mainPage.refreshBtn.text = "Refresh"
mainPage.refreshBtn.backgroundColor = colors.green
mainPage.refreshBtn.textColor = colors.white
end
-- Draw everything
mainPage:draw()
mainDevice:sync()
end
function D.drawSmelterDashboard()
if not smelterPage then
if smelterDevice then
buildSmelterPage()
end
if not smelterPage then return end
end
D.refreshSmelterData()
smelterPage:draw()
smelterDevice:sync()
end
-------------------------------------------------
-- Touch handlers (compatibility bridge)
-- Route monitor_touch events through Opus UI
-------------------------------------------------
function D.handleTouch(x, y)
if not mainPage or not mainDevice then return end
local clickEvent = mainPage:pointToChild(x, y)
if clickEvent and clickEvent.element then
clickEvent.type = 'mouse_click'
clickEvent.key = 'mouse_click'
clickEvent.button = 1
clickEvent.ie = { code = 'mouse_click', x = clickEvent.x, y = clickEvent.y }
if clickEvent.element.focus then
mainPage:setFocus(clickEvent.element)
end
clickEvent.element:emit(clickEvent)
mainPage:sync()
end
end
function D.handleSmelterTouch(x, y)
if not smelterPage or not smelterDevice then return end
local clickEvent = smelterPage:pointToChild(x, y)
if clickEvent and clickEvent.element then
clickEvent.type = 'mouse_click'
clickEvent.key = 'mouse_click'
clickEvent.button = 1
clickEvent.ie = { code = 'mouse_click', x = clickEvent.x, y = clickEvent.y }
if clickEvent.element.focus then
smelterPage:setFocus(clickEvent.element)
end
clickEvent.element:emit(clickEvent)
smelterPage:sync()
end
end
return D
end