Files
Inventory-Manager-CC/manager/client_display.lua
MayaTheShy d97167b21c 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
2026-03-22 20:15:53 -04:00

1271 lines
47 KiB
Lua

-- 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