Files
alchemist-accessibility/accessibility/states/journal.lua
T
Accessibility ModandClaude Opus 5 e1d9995c9f journal: speak the page text, not just the entry title
Opening the journal and selecting an entry announced its title and stopped,
which is an index into content the player could never reach. Page flipping was
silent too.

The screen is three widgets at once: the tab row (q/e), the list of entry
titles down the left (up/down), and the page on the right holding the actual
text (left/right). The descriptor read only the first two.

It now reads options[selectedOption].pages[selectedPageId] and speaks its text
blocks. A page is bounded by what fits on screen, so it is safe as a single
utterance. Layout newlines are flattened first -- splitTextByPages joins the
game's own wrapped lines with \n, so left in they make the reader pause
mid-sentence at every wrap point. Page position is announced only when there is
more than one page, and the blocks are pushed into the console history so [ and
] walk back through a long lore entry after the utterance has gone by.

The tab leads only when the tab changed and the title only when the entry
changed, so flipping pages does not bury the new page under a repeated heading.

That suppression made read() stateful, which is a trap: it is called for the
same focus from four places -- the per-frame poll, the post-keypress announce,
repeatFocus, and the testbed's before/after probes -- so a poll would consume
the "changed" flag and the real announcement would come out headless. The
descriptor now caches its last (key, text) and replays it whenever the key is
unchanged, making read() idempotent; the suppression state advances exactly
once per genuine focus change.

Adds a generic descriptor.reset hook, called from menus.announceScreen. The
memory lives in file-locals that outlive the screen, so without it reopening
the journal on the entry you left it on would read the page with no title.

Testbed asserts the spoken text contains the page body rather than merely being
non-nil. 29 checks, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 01:04:03 +02:00

161 lines
5.4 KiB
Lua

-- states/journal.lua — quests, lore, bestiary, components, recipes, tutorials.
--
-- Visually the screen is three things at once:
--
-- * a row of TABS across the top (Quests / Lore / Bestiary / Components /
-- Recipes / Tutorials), changed with tabLeft / tabRight;
-- * a scrolling LIST of entries down the left, each an icon plus a title,
-- changed with up / down;
-- * the selected entry's TEXT filling the right-hand page, paginated, with a
-- "< 2 / 3 >" counter under it and left / right flipping pages.
--
-- Only the first two were ever announced, so selecting an entry read its title
-- and then nothing — the page holding the actual content was silent, and page
-- flipping was silent too. That is the whole point of the screen.
--
-- The page is `options[selectedOption].pages[selectedPageId]`, a list of text
-- blocks (`{text = ..., color = ..., icon = ...}`). Its length is bounded by
-- what fits on screen, so a page is safe to speak in one go; it is also pushed
-- into the console history so [ and ] can walk it back, the same trick
-- _textscreen.lua uses for the manual.
--
-- Uses getAnyUpvalue because activeTabIndex, options, selectedOption and
-- selectedPageId are touched by different functions here (gotoTab,
-- prepareOptions, selectPage, keypressed).
local history = require("accessibility.history")
local lastTab
local lastEntry
local lastPushedPage
local lastKey
local lastText
-- The page splitter concatenates the game's own wrapped lines with "\n", so a
-- block's text carries hard newlines that are pure layout. Flatten them, or the
-- screen reader pauses mid-sentence at every wrap point.
local function flatten(text)
return (tostring(text):gsub("%s*\n%s*", " "):gsub("^%s+", ""):gsub("%s+$", ""))
end
local function pageBlocks(page)
local blocks = {}
for _, textBlock in ipairs(page) do
if type(textBlock) == "table" and type(textBlock.text) == "string" then
local text = flatten(textBlock.text)
if text ~= "" then
blocks[#blocks + 1] = text
end
end
end
return blocks
end
return {
-- Called by menus.announceScreen each time the journal is opened, so the tab
-- and title lead again rather than being suppressed as "unchanged" from the
-- last visit.
reset = function()
lastTab = nil
lastEntry = nil
lastPushedPage = nil
lastKey = nil
lastText = nil
end,
read = function(stateTable, menus)
local tabIndex = menus.getAnyUpvalue(stateTable, "activeTabIndex")
local tabsList = menus.getAnyUpvalue(stateTable, "tabsList")
local options = menus.getAnyUpvalue(stateTable, "options")
local selected = menus.getAnyUpvalue(stateTable, "selectedOption")
local pageId = menus.getAnyUpvalue(stateTable, "selectedPageId")
if type(tabIndex) ~= "number" or type(tabsList) ~= "table" then
return nil, nil
end
local optionCount = type(options) == "table" and #options or -1
local entry = type(options) == "table" and type(selected) == "number"
and options[selected] or nil
local key = table.concat({
tostring(tabIndex), tostring(selected), tostring(pageId), tostring(optionCount)
}, ":")
-- read() is called from several places for the same focus: the per-frame
-- poll, the post-keypress announce, the "repeat what's focused" key, and
-- the testbed's before/after probes. Only the FIRST read of a given focus
-- may advance the suppression state below -- otherwise a poll would
-- consume the "tab changed" flag and the announcement that follows would
-- come out without its heading. So later reads of the same key replay the
-- text verbatim, which also makes the repeat key say the full thing.
if key == lastKey then
return lastText, key
end
local tab = tabsList[tabIndex]
local tabName = tab and tab.header or ("tab " .. tabIndex)
local parts = {}
-- Lead with the tab only when it just changed, so stepping through a long
-- quest list doesn't repeat "Quests" on every line.
if tabIndex ~= lastTab then
parts[#parts + 1] = tabName
end
if optionCount == 0 then
parts[#parts + 1] = "empty"
elseif entry then
-- Repeating the title on every page flip would bury the page you just
-- turned to, so the title leads only when the entry itself changed.
local entryKey = tostring(tabIndex) .. ":" .. tostring(selected)
if entryKey ~= lastEntry then
parts[#parts + 1] = entry.title or entry.header or entry.name or "?"
parts[#parts + 1] = selected .. " of " .. optionCount
end
local pages = entry.pages
local page = type(pages) == "table" and type(pageId) == "number" and pages[pageId]
if page then
-- Only worth saying which page you are on when there is more than one.
if #pages > 1 then
parts[#parts + 1] = "page " .. pageId .. " of " .. #pages
end
local blocks = pageBlocks(page)
for _, block in ipairs(blocks) do
parts[#parts + 1] = block
end
if #blocks == 0 then
parts[#parts + 1] = "no text"
end
-- Mirror the page into the review buffer so [ and ] can walk it
-- block by block after the utterance has gone by.
local pageKey = entryKey .. ":" .. tostring(pageId)
if pageKey ~= lastPushedPage then
lastPushedPage = pageKey
for _, block in ipairs(blocks) do
pcall(history.append, block, "journal")
end
end
end
lastEntry = entryKey
end
lastTab = tabIndex
lastKey = key
lastText = #parts > 0 and table.concat(parts, ", ") or nil
return lastText, key
end,
}