-- Descriptor for `ingame_popup` — the modal popup shown when you learn a recipe, -- read a lore fragment, unfold a map, or loot from a menu. -- -- WHY THIS EXISTS -- -- `patches/popupstack.lua` covers the *fading corner* popups, and it looked like -- that was all of them. It isn't. `views/fadingPopup.lua` registers only four -- types (journal / loot / bestiary / tutorial); recipe, lore and map popups never -- touch popupStack at all. They are built directly by `modules/{recipe,lore,map, -- loot}Popup.lua` and shown by `states.switch("ingame_popup", {popup = ...})` -- from `states/{ingame,inventory,research}.lua`. -- -- So `ingame_popup` had a spoken name in stateNames ("Popup") and no descriptor: -- the mod announced the word "Popup" and then went silent over the entire body -- of text. That is exactly the reported symptom. -- -- HOW THE TEXT IS REACHED -- -- Each popup module is a factory returning a table whose only member is `draw` -- (recipe/lore/map) — every string is a closure local. So we take `popupObject` -- off the state and read the strings as upvalues of `popupObject.draw`. That is -- the same probe technique the menu descriptors use. Because it probes the popup -- object rather than the state, the startup self-test can only report -- "custom read()" here — the version-bump canary is the testbed check, which -- builds each popup type and asserts its text still comes out. -- -- Blocks are pushed into the console history so `[` / `]` / `\` walk a long lore -- fragment back afterwards — a popup can easily be several paragraphs, which is -- too much to catch in one utterance. local history = require("accessibility.history") -- Layout newlines are pure presentation: `lorePopup`'s footer is one sentence -- with hard breaks in it, and a reader pauses mid-thought at every one. Same -- flattening states/journal.lua does to journal pages. local function flatten(text) if type(text) ~= "string" then return nil end text = text:gsub("%s*\n%s*", " "):gsub("^%s+", ""):gsub("%s+$", "") if text == "" then return nil end return text end local function add(blocks, text) text = flatten(text) if text then blocks[#blocks + 1] = text end end -- Name a list entry. `recipePopup` builds {icon, title}; `lootPopup` builds real -- items, which carry `name` and may stack. local function entryName(entry) if type(entry) ~= "table" then return nil end local name = entry.title or entry.name if type(name) ~= "string" then return nil end local quantity = tonumber(entry.quantity) if quantity and quantity > 1 then return name .. ", " .. quantity end return name end -- The four popup modules differ only in which locals they hold and in what order -- draw() paints them. Reading order follows draw order, so what is spoken matches -- what is on screen top to bottom. -- -- Detection is by which locals exist, since the popup object itself carries no -- type tag: -- itemsList -> loot (real items) -- items -> recipe ({title} rows) -- title + subHeader -> lore -- title -> map local function shape(get) if get("itemsList") ~= nil then return "loot", { "flavourText", "header", "@itemsList" } end if get("items") ~= nil then return "recipe", { "header", "subHeader", "@items", "footer" } end if get("title") ~= nil then if get("subHeader") ~= nil then return "lore", { "text", "header", "title", "subHeader", "footer" } end return "map", { "text", "title", "footer" } end -- Unknown popup type (a future game version adding one): read whatever of the -- usual fields it has rather than falling back to silence. return "unknown", { "text", "header", "subHeader", "title", "footer" } end local function blocksFor(stateTable, menus) local popupObject = menus.getAnyUpvalue(stateTable, "popupObject") if type(popupObject) ~= "table" or type(popupObject.draw) ~= "function" then return nil, nil end local function get(name) return menus.getUpvalue(popupObject.draw, name) end local _, fields = shape(get) local blocks = {} for _, field in ipairs(fields) do if field:sub(1, 1) == "@" then local list = get(field:sub(2)) if type(list) == "table" then for _, entry in ipairs(list) do add(blocks, entryName(entry)) end end else add(blocks, get(field)) end end if #blocks == 0 then return nil, nil end -- The popup table is unique per popup, so its identity is a sound focus key: -- it changes exactly when a new popup is shown and never within one. return blocks, tostring(popupObject) end -- Blocks are separate lines on screen, so they need a sentence break between them -- when spoken -- but most already end in one ("...very old book."), and a blind -- "." join gives NVDA "book.. This", which it reads with an audible stumble. local function join(blocks) local out = blocks[1] for i = 2, #blocks do local sep = out:find("[%.%?!:,]$") and " " or ". " out = out .. sep .. blocks[i] end return out end local lastKey return { reset = function() lastKey = nil end, -- read() is called from the poll, from the post-keypress announce and from -- menus.repeatFocus, so it must be idempotent (see the warning in -- states/journal.lua). It returns the same text for the same key every time; -- only the history push is guarded, since duplicating a popup's paragraphs in -- the review buffer on every frame would swamp it. read = function(stateTable, menus) local blocks, key = blocksFor(stateTable, menus) if not blocks then return nil, nil end if key ~= lastKey then lastKey = key for _, block in ipairs(blocks) do pcall(history.append, block, "text") end end return join(blocks), key end, -- `=` re-reads the popup on demand, for when the utterance went by. details = function(stateTable, menus) local blocks = blocksFor(stateTable, menus) return blocks end, }