Files
alchemist-accessibility/accessibility/hotkeys.lua
T
Accessibility ModandClaude Opus 5 8b1eb44c38 Phase 2: tile examination and surroundings
Game-file footprint unchanged: still one line in main.lua.

- describe.lua: shared spatial formatting. 8-point compass and Chebyshev
  distance, since the game moves 8-directionally and Euclidean would overstate
  diagonal travel. Verbosity setting, because sweeping eight neighbours and
  inspecting a single tile want different amounts of detail.
- states/ingame_observe.lua: narrates the game's existing examine cursor.
- surroundings.lua: probe one direction, scan all eight, or report location,
  without moving anything or touching game state.
- patches/popups.lua: wraps inventoryCursorPopup.set, covering inventory, craft,
  refine and trade in a single patch.

Q3 resolved as augment rather than relay: the game's descriptions carry no
position, which is fine on screen and useless by ear, so distance and direction
are appended.

Examine mode is polled rather than driven by levelCursorPopup. That popup is set
from an animation-completion callback, so unless the player has enabled the
game's quickExaminationPopup option the text arrives a full keypress late -- in
testing you heard the previous tile while standing on the next. Polling
level.cursorX/Y is same-frame and setting-independent.

hotkeys.isCtrlDown/isShiftDown are now overridable fields rather than direct
love.keyboard calls: real hardware state cannot be faked by love.event.push, so
without that seam every modifier-gated hotkey would be untestable.

11/11 testbed checks, history unit tests passing.

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

143 lines
4.6 KiB
Lua

-- hotkeys.lua — the mod's global key layer.
--
-- Sits in front of states.keypressed. Keys we own are handled and CONSUMED (the
-- game never sees them); everything else falls straight through.
--
-- Consuming matters more than it looks. keybindings.getCommand() dispatches on
-- scancode alone and ignores modifiers, checking held modifiers separately via
-- isCommandDown. So Ctrl+Up is seen by the game as plain "up" — a movement — with
-- moveCamera held. If we bound anything to Ctrl+Up and passed it through, the
-- player would move. Hence: our keys are chosen from scancodes the game does not
-- bind at all, and we still consume them.
--
-- Keys the game claims by default (do not use):
-- . 0-9 ` a b backspace c d delete down e escape f g i j kp1-kp9 kpenter
-- lalt lctrl left lshift mouse3 q r ralt rctrl return right rshift s space
-- t tab up x y z
--
-- Note Ctrl = moveCamera and Shift = repeatAction/adjustByFive are HELD modifiers
-- in game. Combining them with an unbound scancode (e.g. Ctrl+PageUp) is safe,
-- because the game maps the scancode to no command and does nothing.
local speech = require("accessibility.speech")
local history = require("accessibility.history")
local hotkeys = {}
--------------------------------------------------------------------------------
-- Where our keys must stay out of the way
--------------------------------------------------------------------------------
-- States that accept free text. Swallowing "[" while the player is naming a
-- character or typing in the developer console would be a real bug.
local textEntryStates = {
ingame_say = true,
new_game = true,
keybindings_menu = true, -- captures raw keys for rebinding
}
local function isTextEntryState()
local ok, states = pcall(require, "states")
if not ok then
return false
end
return textEntryStates[states.getCurrent()] == true
end
--------------------------------------------------------------------------------
-- Modifier helpers
--------------------------------------------------------------------------------
-- Indirected through overridable fields so the testbed can drive modified
-- hotkeys. love.keyboard.isScancodeDown reads real hardware state, which a
-- synthetic love.event.push cannot influence -- without this seam, every
-- modifier-gated key would be untestable.
hotkeys.isCtrlDown = function()
return love.keyboard.isScancodeDown("lctrl", "rctrl")
end
hotkeys.isShiftDown = function()
return love.keyboard.isScancodeDown("lshift", "rshift")
end
local function ctrlDown()
return hotkeys.isCtrlDown()
end
local function shiftDown()
return hotkeys.isShiftDown()
end
--------------------------------------------------------------------------------
-- Bindings
--
-- Each entry: scancode -> { {mods, action}, ... }, checked in order.
-- mods: "ctrl", "shift", or nil for "no modifier required".
--------------------------------------------------------------------------------
local bindings = {}
local function bind(scancode, mods, action)
bindings[scancode] = bindings[scancode] or {}
table.insert(bindings[scancode], {mods = mods, action = action})
end
-- Console review
bind("[", nil, history.reviewPrevious)
bind("]", nil, history.reviewNext)
bind("[", "shift", history.reviewFirst)
bind("]", "shift", history.reviewLast)
bind("\\", nil, history.repeatCurrent)
bind("\\", "shift", history.copyCurrent)
function hotkeys.bind(scancode, mods, action)
bind(scancode, mods, action)
end
local function matches(entry)
if entry.mods == "ctrl" then
return ctrlDown()
elseif entry.mods == "shift" then
return shiftDown()
end
-- No modifier required: reject if one is held, so Shift+[ doesn't also fire [.
return not ctrlDown() and not shiftDown()
end
--------------------------------------------------------------------------------
-- Dispatch
--------------------------------------------------------------------------------
-- Returns true if the key was ours and should NOT reach the game.
function hotkeys.keypressed(_key, scancode)
if isTextEntryState() then
return false
end
local candidates = bindings[scancode]
if not candidates then
return false
end
for _, entry in ipairs(candidates) do
if matches(entry) then
local ok, err = pcall(entry.action)
if not ok then
print("[accessibility] hotkey '" .. tostring(scancode) .. "' error: " .. tostring(err))
end
return true
end
end
return false
end
-- Any key that reaches the game is an explicit player action, so it cancels the
-- pending speech backlog. Whatever that action produces is then spoken fresh.
-- This is the "speak everything queued, unless explicitly stopped" policy.
function hotkeys.onGameKey()
speech.silence()
end
return hotkeys