2026-08-01 22:12:56 +02:00
|
|
|
-- Shared descriptor for the item-grid screens: inventory, craft, refine, trade.
|
|
|
|
|
--
|
|
|
|
|
-- All four build a `views/inventoryWindow` and move a cursor over a grid of
|
|
|
|
|
-- cells. The inventoryCursorPopup patch speaks item details, but ONLY when a cell
|
|
|
|
|
-- actually holds an item -- so empty cells and empty tabs were silent and you
|
|
|
|
|
-- could not tell the cursor had moved. This owns narration for these screens
|
|
|
|
|
-- instead (patches/popups.lua stands down wherever a descriptor exists).
|
|
|
|
|
--
|
|
|
|
|
-- Returns a descriptor; call with the state's window upvalue name.
|
2026-08-02 02:59:21 +02:00
|
|
|
--
|
|
|
|
|
-- Second thing this owns: the ITEM ACTION MENU. Pressing enter/z on an occupied
|
|
|
|
|
-- cell calls inventoryWindow.enableItemMenu(), which builds a little popup list
|
|
|
|
|
-- of the actions that item currently allows ("Switch on", "Wield", "Drink",
|
|
|
|
|
-- "Read"...) from modules/item/getActionsList.lua. It is a second, nested
|
|
|
|
|
-- selection -- up/down move within it, enter activates, anything else cancels --
|
|
|
|
|
-- and nothing about it goes through console.print or the cursor popup, so it was
|
|
|
|
|
-- completely silent. All the player heard was the sound.select click that
|
|
|
|
|
-- enableItemMenu plays on success.
|
|
|
|
|
--
|
|
|
|
|
-- The menu is a closure (modules/item/itemMenu.lua) with no getters, so its
|
|
|
|
|
-- `lines` and `currentSelectedAction` are read as upvalues of its own draw().
|
2026-08-01 22:12:56 +02:00
|
|
|
|
2026-08-03 01:31:58 +02:00
|
|
|
-- Third thing this owns: DETAILS (the `=` key and the settle timer, see
|
|
|
|
|
-- accessibility/details.lua). Two sources, both already composed by the game:
|
|
|
|
|
--
|
|
|
|
|
-- * item.getPopupMessage() -- description, category, condition, refine yields,
|
|
|
|
|
-- lore progress. Read through accessibility/itemdetails.lua.
|
|
|
|
|
-- * the HINT PANEL that craft, refine and trade each draw permanently beside
|
|
|
|
|
-- the grid, and which the cursor popup never contains. Notably PRICE exists
|
|
|
|
|
-- only there (states/trade.lua:156), as do a recipe's component costs and
|
|
|
|
|
-- whether you can afford them.
|
|
|
|
|
--
|
|
|
|
|
-- The three panels turn out to share a shape -- hintDescription, a componentHints
|
|
|
|
|
-- list of {nameText, descriptionText, color}, and a budget line -- so one reader
|
|
|
|
|
-- covers all three rather than three per-state callbacks. Fields a given screen
|
|
|
|
|
-- doesn't use are simply nil (craft has no budget line; trade has no
|
|
|
|
|
-- hintDescription; refine names an output instead of components).
|
|
|
|
|
|
2026-08-01 22:12:56 +02:00
|
|
|
local itemCategories = require("modules.item.categories")
|
2026-08-03 01:31:58 +02:00
|
|
|
local itemdetails = require("accessibility.itemdetails")
|
|
|
|
|
|
|
|
|
|
-- The game marks an unaffordable component by colouring it red. That is the one
|
|
|
|
|
-- fact which decides whether pressing enter does anything at all, so it has to be
|
|
|
|
|
-- spoken -- and the colour is the only place it survives into the hint table.
|
|
|
|
|
local function isUnaffordableColor(color)
|
|
|
|
|
return type(color) == "table" and color[1] and color[1] > 0.8
|
|
|
|
|
and color[2] and color[2] < 0.5
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- craft / refine / trade draw a permanent hint panel beside the grid.
|
|
|
|
|
local function hintBlocks(stateTable, menus)
|
|
|
|
|
local blocks = {}
|
|
|
|
|
|
|
|
|
|
local function add(text)
|
|
|
|
|
if type(text) == "string" then
|
|
|
|
|
text = itemdetails.flatten(text)
|
|
|
|
|
if text ~= "" then
|
|
|
|
|
blocks[#blocks + 1] = text
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- craft: the recipe's brewing instructions. refine: how this refinement works.
|
|
|
|
|
add(menus.getAnyUpvalue(stateTable, "hintDescription"))
|
|
|
|
|
|
|
|
|
|
-- refine: what you get out, and how much of it you already have.
|
|
|
|
|
local outputName = menus.getAnyUpvalue(stateTable, "outputName")
|
|
|
|
|
if outputName then
|
|
|
|
|
local quantity = menus.getAnyUpvalue(stateTable, "outputQuantityText")
|
|
|
|
|
add("Yields " .. outputName .. (quantity and (", " .. quantity) or ""))
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- craft: one per recipe component. trade: the price, as a single "component".
|
|
|
|
|
local componentHints = menus.getAnyUpvalue(stateTable, "componentHints")
|
|
|
|
|
if type(componentHints) == "table" then
|
|
|
|
|
for _, hint in ipairs(componentHints) do
|
|
|
|
|
if type(hint) == "table" and hint.nameText then
|
|
|
|
|
add("Costs " .. hint.nameText
|
|
|
|
|
.. (hint.descriptionText and (", " .. hint.descriptionText) or "")
|
|
|
|
|
.. (isUnaffordableColor(hint.color) and ", cannot afford" or ""))
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- refine / trade: "You already have: x2.00 green paste".
|
|
|
|
|
local budgetName = menus.getAnyUpvalue(stateTable, "budgetNameText")
|
|
|
|
|
if budgetName then
|
|
|
|
|
local budgetDescription = menus.getAnyUpvalue(stateTable, "budgetDescription")
|
|
|
|
|
local budgetQuantity = menus.getAnyUpvalue(stateTable, "budgetQuantityText")
|
|
|
|
|
add((budgetDescription or "You have:") .. " "
|
|
|
|
|
.. (budgetQuantity or "") .. " " .. budgetName)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return blocks
|
|
|
|
|
end
|
2026-08-01 22:12:56 +02:00
|
|
|
|
|
|
|
|
return function(options)
|
|
|
|
|
options = options or {}
|
|
|
|
|
|
|
|
|
|
local windowName = options.window or "inventoryWindow"
|
|
|
|
|
local lastTab
|
2026-08-02 02:59:21 +02:00
|
|
|
local lastMenu
|
|
|
|
|
|
|
|
|
|
-- Read the open item action menu, if there is one.
|
|
|
|
|
local function readItemMenu(window, menus)
|
|
|
|
|
local itemMenu = window.itemMenu
|
|
|
|
|
if type(itemMenu) ~= "table" or type(itemMenu.draw) ~= "function" then
|
|
|
|
|
lastMenu = nil
|
|
|
|
|
return nil, nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local lines = menus.getUpvalue(itemMenu.draw, "lines")
|
|
|
|
|
local selected = menus.getUpvalue(itemMenu.draw, "currentSelectedAction")
|
|
|
|
|
|
|
|
|
|
if type(lines) ~= "table" or type(selected) ~= "number" then
|
|
|
|
|
return nil, nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local line = lines[selected]
|
|
|
|
|
if type(line) ~= "table" or not line.text then
|
|
|
|
|
return nil, nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local parts = {}
|
|
|
|
|
|
|
|
|
|
-- Lead with the item only when this menu just opened, so arrowing through
|
|
|
|
|
-- two or three actions doesn't repeat the name every time. `lastMenu` holds
|
|
|
|
|
-- the menu table itself rather than a flag, which keeps read() idempotent:
|
|
|
|
|
-- calling it twice for the same focus produces the same words both times.
|
|
|
|
|
if itemMenu ~= lastMenu then
|
|
|
|
|
lastMenu = itemMenu
|
|
|
|
|
local ok, item = pcall(window.getItemOnCursor)
|
|
|
|
|
if ok and item and item.name then
|
|
|
|
|
parts[#parts + 1] = item.name
|
|
|
|
|
end
|
|
|
|
|
parts[#parts + 1] = "actions"
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
parts[#parts + 1] = line.text
|
|
|
|
|
parts[#parts + 1] = selected .. " of " .. #lines
|
|
|
|
|
|
|
|
|
|
return table.concat(parts, ", "),
|
|
|
|
|
"itemmenu:" .. tostring(selected) .. "/" .. tostring(#lines)
|
|
|
|
|
end
|
2026-08-01 22:12:56 +02:00
|
|
|
|
2026-08-03 01:31:58 +02:00
|
|
|
-- The window closure, or nil if the screen isn't built yet.
|
|
|
|
|
local function getWindow(stateTable, menus)
|
|
|
|
|
local window = menus.getAnyUpvalue(stateTable, windowName)
|
|
|
|
|
if type(window) ~= "table" or not window.getItemOnCursor then
|
|
|
|
|
return nil
|
|
|
|
|
end
|
|
|
|
|
return window
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-01 22:12:56 +02:00
|
|
|
return {
|
2026-08-02 02:59:21 +02:00
|
|
|
reset = function()
|
|
|
|
|
lastTab = nil
|
|
|
|
|
lastMenu = nil
|
|
|
|
|
end,
|
|
|
|
|
|
2026-08-03 01:31:58 +02:00
|
|
|
-- Everything the focus announcement is too terse to say. See details.lua.
|
|
|
|
|
details = function(stateTable, menus, verbose)
|
|
|
|
|
local window = getWindow(stateTable, menus)
|
|
|
|
|
if not window then
|
|
|
|
|
return nil
|
|
|
|
|
end
|
2026-08-02 02:59:21 +02:00
|
|
|
|
2026-08-03 01:31:58 +02:00
|
|
|
-- The slider owns speech during quantity selection (patches/slider.lua);
|
|
|
|
|
-- reading a description over the number being adjusted would fight it.
|
|
|
|
|
if menus.getAnyUpvalue(stateTable, "confirmationWindowEnabled") then
|
|
|
|
|
return nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Note the item action menu is NOT excluded here, unlike in read(). The
|
|
|
|
|
-- menu is modal over the grid so the highlighted VERB is what read()
|
|
|
|
|
-- announces, but the thing you want described is still the item the menu
|
|
|
|
|
-- belongs to -- and that is exactly when you want to know what "Drink"
|
|
|
|
|
-- would be drinking.
|
|
|
|
|
local ok, item = pcall(window.getItemOnCursor)
|
|
|
|
|
if not ok or not item then
|
|
|
|
|
return nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local blocks = itemdetails.blocks(item, verbose) or {}
|
|
|
|
|
|
|
|
|
|
for _, block in ipairs(hintBlocks(stateTable, menus)) do
|
|
|
|
|
blocks[#blocks + 1] = block
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
if #blocks == 0 then
|
|
|
|
|
return nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return blocks
|
|
|
|
|
end,
|
|
|
|
|
|
|
|
|
|
read = function(stateTable, menus)
|
|
|
|
|
local window = getWindow(stateTable, menus)
|
|
|
|
|
if not window then
|
2026-08-02 02:59:21 +02:00
|
|
|
return nil, nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- The action menu is modal over the grid: while it is open, up/down
|
|
|
|
|
-- move within IT, not over the cells. So it is checked first, and the
|
|
|
|
|
-- grid cell stays quiet underneath it.
|
|
|
|
|
if window.itemMenu then
|
|
|
|
|
return readItemMenu(window, menus)
|
|
|
|
|
end
|
|
|
|
|
lastMenu = nil
|
|
|
|
|
|
2026-08-01 22:12:56 +02:00
|
|
|
-- While the quantity confirmation window is open, the slider owns
|
|
|
|
|
-- speech (see patches/slider.lua). Announcing the grid cell over the
|
|
|
|
|
-- top of it would fight the number the player is adjusting.
|
|
|
|
|
local confirming = menus.getAnyUpvalue(stateTable, "confirmationWindowEnabled")
|
|
|
|
|
if confirming then
|
|
|
|
|
return nil, nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local position = window.cursorPosition
|
|
|
|
|
if type(position) ~= "number" then
|
|
|
|
|
return nil, nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- activeTabIndex lives inside the window closure, not the state file,
|
|
|
|
|
-- so reach it through one of the window's own methods.
|
|
|
|
|
local tabIndex
|
|
|
|
|
if window.adjustTab then
|
|
|
|
|
tabIndex = menus.getUpvalue(window.adjustTab, "activeTabIndex")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local parts = {}
|
|
|
|
|
|
|
|
|
|
if tabIndex and tabIndex ~= lastTab then
|
|
|
|
|
lastTab = tabIndex
|
|
|
|
|
local category = itemCategories[tabIndex]
|
|
|
|
|
parts[#parts + 1] = (category and category.header) or ("tab " .. tabIndex)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local ok, item = pcall(window.getItemOnCursor)
|
|
|
|
|
|
|
|
|
|
if ok and item then
|
|
|
|
|
parts[#parts + 1] = item.name or item.templateId or "item"
|
|
|
|
|
if item.quantity and item.quantity > 1 then
|
|
|
|
|
parts[#parts + 1] = "x" .. item.quantity
|
|
|
|
|
end
|
2026-08-02 02:59:21 +02:00
|
|
|
if item.isEquipped then
|
|
|
|
|
parts[#parts + 1] = "equipped"
|
|
|
|
|
end
|
2026-08-01 22:12:56 +02:00
|
|
|
if item.isUnavailable then
|
|
|
|
|
parts[#parts + 1] = "unavailable"
|
|
|
|
|
end
|
|
|
|
|
else
|
|
|
|
|
parts[#parts + 1] = "empty"
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
parts[#parts + 1] = "slot " .. position
|
|
|
|
|
|
2026-08-02 02:59:21 +02:00
|
|
|
-- isEquipped is part of the focus key on purpose: after "Switch on"
|
|
|
|
|
-- the menu closes and the cursor has not moved, so without it the
|
|
|
|
|
-- re-read would be suppressed as unchanged and the player would get no
|
|
|
|
|
-- confirmation that the action took effect.
|
2026-08-01 22:12:56 +02:00
|
|
|
return table.concat(parts, ", "),
|
|
|
|
|
table.concat({
|
|
|
|
|
tostring(tabIndex), tostring(position),
|
|
|
|
|
tostring(ok and item and (item.name or "?")),
|
2026-08-02 02:59:21 +02:00
|
|
|
tostring(ok and item and item.isEquipped or false),
|
2026-08-01 22:12:56 +02:00
|
|
|
}, ":")
|
|
|
|
|
end,
|
|
|
|
|
}
|
|
|
|
|
end
|