- Add autorun/startup.lua for Opus package integration: - Detects role from config files (.manager_config, .client_config, etc.) - Registers reboot listener as kernel hook - Launches program via shell.openForegroundTab() - Client role also opens dropperController in separate tab - Override grid eventHandler on actionable grids (itemGrid, smeltTab, craftTab) to emit grid_select on single mouse_click, enabling monitor touch to trigger actions immediately instead of requiring double-click
1099 lines
40 KiB
Lua
1099 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()
|
|
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()
|
|
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 |