Opus initChildren uses pairs() which has non-deterministic ordering. The keyboard overlay occupies the same screen space as alertBar, footerBar, and bottomBar. If those bars end up later in the children array, they render on top and hide the keyboard. Added raise() after enabling the keyboard to move it to the end of the children array, ensuring it renders on top.
1237 lines
46 KiB
Lua
1237 lines
46 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 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()
|
|
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,
|
|
draw = function(self)
|
|
self:clear(colors.black)
|
|
-- Keyboard toggle button
|
|
local kbLabel = showKeyboard and " X " or " ? "
|
|
local kbBg = showKeyboard and colors.red or colors.purple
|
|
self:write(1, 1, kbLabel, kbBg, colors.white)
|
|
-- Search query display
|
|
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,
|
|
|
|
-- 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,
|
|
},
|
|
|
|
-- 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, -- prevent auto-enable; toggled manually
|
|
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
|
|
-- Draw letter keys
|
|
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
|
|
-- Draw special keys
|
|
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 -- consume click even if no zone hit
|
|
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)
|
|
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 (must resize to recompute all child dimensions
|
|
-- from the monitor device, since Page:postInit defaulted to UI.term)
|
|
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()
|
|
|
|
-- 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:resize()
|
|
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 |