OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / autoloads / keymap.gd

1121 lines · gd ref:
extends Node
## KEYMAPS ARE DATA — one table, with contexts, conflict-checked at boot.
##
## Why this exists (hyper-games #22 follow-up, the keymap audit):
## bindings lived in two places that could not see each other — InputMap actions in
## project.godot, and ~78 hardcoded `event.keycode == KEY_X` comparisons across 97
## GDScript files. Adding Ctrl+Z for undo meant grepping the project by hand to
## prove the key was free. It was not: `toggle_slowmo` is bound to plain Z, and
## Godot matches actions with `exact_match = false` by DEFAULT, which ignores
## modifiers — so Ctrl+Z undid an edit AND toggled slow motion. Shipped in 0.847.0,
## found by scenes/test/keymap_audit.gd within minutes of writing it.
##
## The model is Blender's, in the one respect that matters: a keymap is DATA
## (context -> action -> binding), so bindings are enumerable, rebindable and
## checkable, instead of being statements buried in an input handler. Godot's
## InputMap gets us the first half and cannot express the second: it has no notion
## of CONTEXT, so "B selects a brush while building" and "B does something else
## while walking" have to be resolved by whoever happens to handle the event first.
## Contexts live here for exactly that reason.
##
## TAP vs HOLD is a real distinction, not a naming choice:
##   - a HOLD action (movement) must tolerate modifiers — you have to be able to
##     walk while holding ctrl to crouch — so it is matched non-exact;
##   - a TAP action (a toggle, a shortcut) must NOT, or every modifier combination
##     triggers it, which is the bug above.
## The audit measures this by synthesising a modified event and asking the engine,
## rather than trusting either the docs or this comment.
##
## Bindings the player changes are persisted to user://keymap.json — deliberately
## NOT through Settings, whose set_value validates against a fixed SCHEMA that has
## no room for arbitrary action names.

const SAVE_PATH := "user://keymap.json"
## The keyboard-layout rule (keymap step 5, §6) — shared with the operators.
const KeyRules := preload("res://autoloads/key_rules.gd")

## CONTEXTS, and what each one does to the ones under it.
##
## Blender consults keymaps in order (window, screen, area, region, mode, tool) and
## an item either handles an event or passes it through. Same idea, three fields:
##
##   always     never suppressed. Screenshots, metrics, Escape — a mode must
##              not be able to trap you with no way to look at anything or get out.
##   exclusive  suppresses every non-`always` context below it on the stack. This is
##              what makes a MODE a mode: while building you are a camera, not a
##              body, so `jump` should not fire and `B` means what the builder says.
##   (neither)  ordinary layer, active when pushed.
##
## Without exclusivity the builder context only ADDED bindings, so every player
## action stayed live underneath it — which is why `B` could not safely mean two
## things, the exact problem contexts were introduced to solve.
const CONTEXTS: Dictionary = {
	"global":  {"always": true},
	"player":  {},
	# ADDITIVE, not exclusive, and the distinction is load-bearing. The level map
	# pushes "builder" the whole time it is loaded, but you are still a PLAYER there
	# — the editor keys are dev bindings layered over ordinary play. Making this
	# exclusive silently killed toggle_light and respawn on that map; the gate caught
	# it immediately, which is the entire reason the gate asserts suppression against
	# a context that is meant to suppress rather than against this one.
	#
	# REQUIRES build_mode since the 2026-10-01 key audit, and that reverses the
	# paragraph above for a reason it could not see: additive meant K B C G E N F V
	# L Z [ ] - = were live while WALKING on the level map -- L fired lizard-debug
	# AND entity links, and every gameplay letter added later would collide. The
	# editor's tools are the editor's; F4 is the way in. toggle_light and respawn
	# keep working on that map because nothing here suppresses `player` any more.
	"builder": {"requires": "build_mode"},
	# The builder MODE: a free-fly camera, not a body. Genuinely exclusive — while
	# flying you have no legs, so `jump` must not fire.
	#
	# `keeps` is the other half of that, and the mode gate is what forced it into
	# existence: exclusivity alone ALSO suppressed the editing keys, so entering build
	# mode would have disabled carve, select and fill — the tools the mode exists for.
	# An exclusive context therefore has to say what SURVIVES it. "Takes your legs,
	# keeps your tools" is the whole rule, and it is data rather than a special case
	# buried in the lookup.
	"build_mode": {"exclusive": true, "keeps": ["builder", "face_sel"]},
	# SOMETHING IS SELECTED (UV controls, docs/uv-controls-design.md §4). Pushed / popped
	# by map_dc_level while the selection is non-empty, so the arrows, PgUp/PgDn, Home
	# and End mean "align the selection's textures" only when there is one — and F1
	# lists them only then. Kept by build_mode, like the builder's tools.
	"face_sel": {"requires": "build_mode"},
	# A TOOL IS UP (editor redesign E0, docs/editor-redesign.md §2). Pushed and popped by
	# OperatorStack. While it is active and RMB is NOT held, the tool owns Ctrl / Shift /
	# Space (snap invert / precision / confirm) instead of the fly camera — Blender's
	# rule that a running modal operator owns its keys. WASD keep moving.
	"tool": {"requires": "build_mode"},
	# TYPING. Exclusive and set automatically from the focused text field (_process), so
	# a search box, the chat line or a SpinBox gets every key: no builder tool fires and
	# movement stops reading the keyboard (movement_allowed). Before this, typing "brick"
	# into the texture search was B select, R, I intersect, C knife — and typing in chat
	# walked you, because movement POLLS the keyboard and focus never reached it.
	# `always` contexts (Esc, F-keys) stay live: a field must not trap you.
	"text_input": {"exclusive": true},
	# A HELPER POPUP IS OPEN (editor redesign E3: tool popup, context menu) — the same
	# guarantee as text_input for popups that have no text field: no tool fires
	# underneath and the fly camera stops (movement_allowed), so picking from a menu
	# never also flies you somewhere. Pushed / popped by the popup itself.
	"menu": {"exclusive": true},
	# FLYING (keymap step 1, docs/keymap-blender-279.md §2a; user 2026-10-12 "suppress
	# while flying like blender"). On the stack ONLY while RMB is held or the Shift+F fly
	# modal is up (GameMode). Exclusive and keeps nothing, as Blender's fly modal consumes
	# every event: no editor key fires while you fly, so Ctrl / Shift / Space — the fly
	# camera's while flying — are free modifiers for commands the rest of the time.
	"fly": {"exclusive": true},
}

## Which context each PRE-EXISTING project.godot action belongs to.
##
## Their bindings deliberately stay in project.godot — that file is real data, it is
## editable in Godot's own UI, and churning working gameplay bindings in the same
## change as the context mechanism would risk breaking movement to prove a point.
## What lives here is MEMBERSHIP and DISPATCH; new actions (builder.*) are declared
## fully in DEFAULTS below. Migrating the rest into one table is a follow-up, and
## nothing depends on the order it happens in.
const MEMBERSHIP: Dictionary = {
	"move_forward": "player", "move_back": "player",
	"move_left": "player", "move_right": "player",
	"jump": "player", "crouch": "player", "sprint": "player", "walk": "player",
	"toggle_light": "player",
	# Chat is a PLAYER action: the editor's exclusive build mode suppresses it (user
	# 2026-10-18: "t still brings up chat menu in editor which is very wrong"). The
	# player context is the base of the stack, so chat works from launch (the lobby too).
	"toggle_chat": "player",
	# Always reachable, whatever mode you are in.
	"ui_cancel": "global",
}

## context -> action -> {key, ctrl, shift, alt, hold, label}
## `hold` marks a continuous action (non-exact matching, see above).
const DEFAULTS: Dictionary = {
	# F-KEYS ARE DEVELOPER / DIAGNOSTIC, letters are gameplay verbs. That rule is why
	# these three moved: fly was on F5, which `respawn` already owned, so one press
	# did both; and the climb-debug visual was on K, which is the CARVE key, so on the
	# level map one press carved a hole AND toggled debug lines. Both were invisible
	# because they were hardcoded keycode comparisons that no table could see.
	# THE F ROW SELECTS CONTEXT. That is the rule; everything else on the row moved to
	# make room for it, because "F-keys are diagnostics" was never a principle — it
	# was a description of which keys happened to be free.
	#
	# Tab carries the switch you make constantly (Blender puts Object/Edit there for
	# exactly that reason: one key, left hand, no reach), while the F row is for going
	# somewhere SPECIFIC and has room for the modes we do not have yet. Maya's
	# F2/F3/F4 menu-set switching is the precedent for the row itself.
	#
	# F9-F12 stay out of the scheme: window managers and Godot commonly grab F11 for
	# fullscreen, and laptops need Fn for the far end of the row.
	"global": {
		"global.help":        {"key": KEY_F1, "label": "Show / hide the key list"},
		# ONE KEY FOR THE MODE. There were three — Tab toggled, F2 set play, F3 set
		# build — on the reasoning that "a mode is a value, not the parity of how
		# many times you pressed something". That argument only pays off for a
		# PROGRAMMATIC caller, and those call GameMode.set_mode() directly; for a
		# person it was three keys for two states, and Tab additionally collided
		# with Godot's ui_focus_next the moment the editor grew focusable panels.
		#
		# F4 because F2 and F3 are now free and this is the editor's own key, next
		# to the F-row it used to share. Looking around is RMB-hold, so there is no
		# second sub-mode key either.
		"global.mode_toggle": {"key": KEY_F4, "label": "Enter / exit build mode"},
		# DIAGNOSTICS KEEP THEIR KEY BUT GAIN Ctrl — same row, same mnemonic, and the
		# modifier says "meta, not gameplay". toggle_metrics HAD to move regardless:
		# it was on bare F3, which the build mode now owns.
		"toggle_metrics":     {"key": KEY_F3, "ctrl": true, "label": "Performance metrics"},
		# THE REST OF THE DIAGNOSTICS, DECLARED (key audit 2026-10-01). They were
		# keycode comparisons in map_core / player / keet_game_state / game, and
		# four of them fired a SECOND action on the same press: Ctrl+F8 (unstick +
		# CSG overlay), Ctrl+F6 (climb debug + wireframe, whose check ignored Ctrl),
		# Ctrl+F9 (LOD colours + the bare-F9 room overlay) and L (lizard + entity
		# links). Ctrl+F<n> keeps its key where it was alone; the one that collided
		# moves to Ctrl+SHIFT+F<n> -- the precedent is the settings dump on
		# Ctrl+Shift+F5 -- so the F key still names the area.
		"debug.teleport_far":   {"key": KEY_F2, "ctrl": true, "label": "Teleport 100 km and back (precision A/B)"},
		"debug.terrain_probe":  {"key": KEY_F5, "ctrl": true, "label": "Terrain ground probe"},
		"debug.settings_dump":  {"key": KEY_F5, "ctrl": true, "shift": true, "label": "Dump this map's tuned settings"},
		"debug.collision_hull": {"key": KEY_F7, "ctrl": true, "label": "Collision hull overlay"},
		"debug.wireframe":      {"key": KEY_F7, "ctrl": true, "shift": true, "label": "Wireframe + triangle count"},
		"debug.csg_edits":      {"key": KEY_F8, "ctrl": true, "shift": true, "label": "Show the CSG edits"},
		"debug.lod_colors":     {"key": KEY_F9, "ctrl": true, "label": "Terrain LOD colours"},
		"debug.room_overlay":   {"key": KEY_F9, "ctrl": true, "shift": true, "label": "Room membership overlay"},
		"debug.player_dump":    {"key": KEY_F10, "ctrl": true, "label": "Dump the last 3 s of player state"},
		"global.screenshot":    {"key": KEY_F12, "label": "Screenshot"},
	},
	"player": {
		# CSG EDIT TYPE — the number row, while PLAYING.
		#
		# The comment on the build_mode block says no tool may take 1-9 because
		# they are weapon slots. There are no weapons yet, and these are declared
		# in `player` rather than `builder` for the reason that block gives:
		# `build_mode` is EXCLUSIVE and suppresses `player`, so 1-4 mean select
		# mode while building and edit type while playing, and the two can never
		# both be live. `builder` is additive and WOULD have collided.
		#
		# The order is map_dc_terrain.gd's EDIT_MODES, unchanged, so the muscle
		# memory of the map that has had these all along still works.
		"player.csg_box":       {"key": KEY_1, "label": "Carver brush: box"},
		"player.csg_sphere":    {"key": KEY_2, "label": "Carver brush: smooth sphere"},
		"player.csg_hard":      {"key": KEY_3, "label": "Carver brush: hard sphere"},
		"player.csg_voxelize":  {"key": KEY_4, "label": "Carver brush: voxelize the brush under the crosshair"},
		# THE CARVER IS A WEAPON (key audit 2026-10-01): LMB fires it, 1-4 are its
		# brushes -- the number row is weapon slots, which is what rule 2 always
		# reserved it for. It used to be RMB, Shift+RMB to ADD and Shift+wheel for
		# size, and Shift is SPRINT: carving while running silently added instead of
		# removing, and scrolling while running resized instead of zooming.
		# `any_mods`: you fire and grapple while sprinting, so these two match with
		# modifiers held (tapped() is otherwise exact).
		"player.fire":          {"mouse": MOUSE_BUTTON_LEFT, "any_mods": true, "label": "Fire the carver"},
		"grapple":              {"mouse": MOUSE_BUTTON_RIGHT, "any_mods": true, "label": "Grapple"},
		"player.carve_mode":    {"key": KEY_Q, "label": "Carver: remove / add"},
		"player.carve_smaller": {"key": KEY_MINUS, "label": "Carver: smaller brush"},
		"player.carve_bigger":  {"key": KEY_EQUAL, "label": "Carver: bigger brush"},
		# RESPAWN IS NOT A MODE, so it has no business on the F row. Backspace is
		# reachable, unambiguous, and cannot be confused with a mode switch.
		"respawn":              {"key": KEY_BACKSPACE, "label": "Respawn"},
		"player.climb_debug":   {"key": KEY_F6, "ctrl": true, "label": "Wall-climb debug visuals"},
		"player.camera_unstick":{"key": KEY_F8, "ctrl": true, "label": "Unstick the camera spring arm"},
		# Was a bare `keycode == KEY_L` in player_procedural -- which is also
		# builder.entity_links on the level map. Beside climb debug, which it is.
		"player.lizard_debug":  {"key": KEY_F6, "ctrl": true, "shift": true, "label": "Lizard-walk debug (crawl on flat ground)"},
		# player.fly is GONE. Free-flying IS build mode — a separate toggle would have
		# been a second way into the same state, and the two would drift.
	},
	"builder": {
		# KEYMAP STEP 3 (docs/keymap-blender-279.md §3a): K is Blender's knife now. Build-mode
		# carve is menu / toolbar only (user 2026-10-12: carving keys are the play-mode
		# carver's — LMB fire, Q mode, 1-4 brush).
		"builder.carve":        {"label": "Carve a sphere at the crosshair"},
		# Crosshair pick: LMB click selects (Blender left-select); B is box select now.
		"builder.select":       {"label": "Select vertex / edge / face under the crosshair"},
		# 2.79 with use_select_all_toggle (Blender_27x.py): A toggles all, Alt+A none,
		# Ctrl+I inverts. Shift+B (clear) retires.
		"builder.select_all":    {"key": KEY_A, "label": "Select all / none (toggle)"},
		"builder.deselect":      {"key": KEY_A, "alt": true, "label": "Clear the selection"},
		"builder.select_invert": {"key": KEY_I, "ctrl": true, "label": "Invert the selection"},
		"builder.select_box":    {"key": KEY_B, "label": "Box select (drag a rectangle; Shift adds)"},
		"builder.select_circle": {"key": KEY_C, "label": "Circle select (paint in with LMB, Shift paints out, wheel resizes, RMB ends)"},
		# SELECT LINKED / GROUPED (keymap step 6.3; Blender: L / Shift+L linked-pick, Ctrl+L
		# linked, Shift+G grouped). "Linked" = the same shape or group; Ctrl+L = touching.
		"builder.select_linked_pick":   {"key": KEY_L, "label": "Select linked: the shape / group under the pointer"},
		"builder.deselect_linked_pick": {"key": KEY_L, "shift": true, "label": "Deselect the shape / group under the pointer"},
		"builder.select_linked":        {"key": KEY_L, "ctrl": true, "label": "Select linked: everything touching the selection"},
		"builder.select_grouped":       {"key": KEY_G, "shift": true, "label": "Select grouped (menu)"},
		"builder.select_same_group":    {"label": "Same group / shape"},
		"builder.select_same_material": {"label": "Same material"},
		"builder.select_same_texture":  {"label": "Same texture"},
		"builder.select_same_type":     {"label": "Same shape type"},
		"builder.move_in":      {"key": KEY_BRACKETLEFT, "label": "Move selection in one grid step"},
		"builder.move_out":     {"key": KEY_BRACKETRIGHT, "label": "Move selection out one grid step"},
		# = bigger / - smaller, the same as the carve brush in play mode.
		"builder.grid_coarser": {"key": KEY_EQUAL, "label": "Coarser grid"},
		"builder.grid_finer":   {"key": KEY_MINUS, "label": "Finer grid"},
		# Blender's key for it. Shift is fly-fast at top level, so Shift+Tab also speeds
		# the camera for the instant it is held — the same accepted trade as Shift+Z.
		"builder.snap_toggle":  {"key": KEY_TAB, "shift": true, "label": "Snap to grid on / off"},
		# map_dc_world only — the embed depth of the level in the procedural
		# terrain (level-mesh-implementation.md §5a). Declared here rather than
		# read as a keycode in the map because everything in the builder context
		# is, and the boot conflict check only sees what is declared. Maps with
		# no terrain simply never handle them.
		# Alt+PgDn / Alt+PgUp since keymap step 3: , . are Blender's pivot keys.
		"builder.lift_down":    {"key": KEY_PAGEDOWN, "alt": true, "label": "Lower the level into the terrain"},
		"builder.lift_up":      {"key": KEY_PAGEUP, "alt": true, "label": "Raise the level out of the terrain"},
		# map_dc_world only. The shell subtract now re-derives ITSELF when the level
		# changes, debounced by RESHELL_QUIET, so this is no longer the only way to
		# get an up-to-date subtract. It stays for the two cases that automation
		# cannot cover: the debounce is keyed on the shell cache key, which a face
		# nudged along its own normal leaves identical, and forcing a re-derive is
		# how you SEE what a deleted boundary brush does to the seal instead of
		# reasoning about it. ~2.8 s hitch.
		"builder.reshell":      {"key": KEY_SEMICOLON, "label": "Re-derive the level shell subtract"},
		# HOLD: aim, hold, aim at the opposing face, release. The only binding here
		# that needs the key-up, which is why `released()` exists.
		"builder.fill":         {"key": KEY_F, "hold": true, "label": "Two-point face-to-face fill"},
		# CLIP and DELETE together are what make a doorway reachable: cut, cut, delete
		# the middle. Neither existed — add_face can only shrink a convex solid, and
		# remove_brush had no binding at all.
		"builder.brush_view":   {"key": KEY_V, "label": "Show / hide brush outlines"},
		# CLEARANCE volumes are air the level owns — the headroom over a flight of
		# stairs, the bore of an arch — and the terrain is cut out of them. Nothing
		# renders them, so without this the only evidence one exists is the hole in
		# the hillside, which you cannot see from inside the passage. The view also
		# makes them PICKABLE, which is what makes their extents editable with the
		# ordinary face tools rather than needing a parallel set of their own.
		"builder.clearance_view": {"key": KEY_V, "shift": true, "label": "Show / edit clearance volumes"},
		# The WIRING, which is invisible in a rendered level: a trigger_teleport is
		# a touch volume with no appearance of its own, and the destination it
		# sends you to is a point entity with none either. Nothing on screen says
		# they are connected, or which of the four destinations a given pad uses.
		# Shift+Alt+L since keymap step 6.3: L is Blender's select linked. An Overlays row
		# since step 6.5 — and still on its key (user 2026-10-12: keep the keys).
		"builder.entity_links": {"key": KEY_L, "shift": true, "alt": true, "label": "Show / hide entity link lines"},
		# OVERLAYS (keymap step 6.5, docs/keymap-blender-279.md §6a.5). The live cutters'
		# wire had no switch; Shift+Alt+C is free in 2.79. Shift+Alt+Z is Blender 2.8's
		# show_overlays: every overlay off / back as it was, each keeping its own state.
		"builder.cutters_view": {"key": KEY_C, "shift": true, "alt": true, "label": "Show / hide live cutters"},
		# Curves (their wire + points) as an overlay (2026-10-18); Shift+Alt+K is free in 2.79.
		"builder.curves_view": {"key": KEY_K, "shift": true, "alt": true, "label": "Show / hide curves (wire and points)"},
		"builder.overlays_all": {"key": KEY_Z, "shift": true, "alt": true, "label": "Overlays: all off / back on"},
		# The active GRID drawn on the surface you are aiming at (TrenchBroom's
		# GridRenderer, #31): the grid size existed with nothing showing it.
		"builder.grid_view": {"key": KEY_APOSTROPHE, "label": "Show / hide the grid"},
		# BLENDER'S NUMPAD VIEWS (#109; docs/editor-ortho-view.md, from blender_default.py
		# 1683-1765). scenes/game/editor_view.gd runs them; listed in the View menu for
		# keyboards without a numpad.
		"view.persp_ortho":  {"key": KEY_KP_5, "label": "Perspective / orthographic"},
		"view.front":        {"key": KEY_KP_1, "label": "Front view"},
		"view.back":         {"key": KEY_KP_1, "ctrl": true, "label": "Back view"},
		"view.right":        {"key": KEY_KP_3, "label": "Right view"},
		"view.left":         {"key": KEY_KP_3, "ctrl": true, "label": "Left view"},
		"view.top":          {"key": KEY_KP_7, "label": "Top view"},
		"view.bottom":       {"key": KEY_KP_7, "ctrl": true, "label": "Bottom view"},
		"view.orbit_down":   {"key": KEY_KP_2, "label": "Orbit down 15°"},
		"view.orbit_left":   {"key": KEY_KP_4, "label": "Orbit left 15°"},
		"view.orbit_right":  {"key": KEY_KP_6, "label": "Orbit right 15°"},
		"view.orbit_up":     {"key": KEY_KP_8, "label": "Orbit up 15°"},
		"view.pan_down":     {"key": KEY_KP_2, "ctrl": true, "label": "Pan down"},
		"view.pan_left":     {"key": KEY_KP_4, "ctrl": true, "label": "Pan left"},
		"view.pan_right":    {"key": KEY_KP_6, "ctrl": true, "label": "Pan right"},
		"view.pan_up":       {"key": KEY_KP_8, "ctrl": true, "label": "Pan up"},
		"view.opposite":     {"key": KEY_KP_9, "label": "Opposite view"},
		"view.selected":     {"key": KEY_KP_PERIOD, "label": "Frame the selection"},
		# Keymap step 6 (Blender's view_all / view_center_cursor / view_center_pick). Home
		# yields to UV fit while faces are selected (editor_view.gd).
		"view.all":           {"key": KEY_HOME, "label": "View all"},
		"view.all_center":    {"key": KEY_C, "shift": true, "label": "View all + 3D cursor to the origin"},
		"view.center_cursor": {"key": KEY_HOME, "alt": true, "label": "Centre the view on the 3D cursor"},
		"view.center_pick":   {"key": KEY_F, "alt": true, "label": "Centre the view on the point under the pointer"},
		# THE 3D CURSOR (#109, docs/editor-3d-cursor.md; Blender: Shift+RMB place / drag,
		# Shift+S snap menu). The snap commands have no key of their own: they run from
		# the Shift+S menu, the RMB-tap context menu and the View menu.
		"builder.cursor_place": {"mouse": MOUSE_BUTTON_RIGHT, "shift": true, "label": "Place the 3D cursor (drag: slide it)"},
		"builder.cursor_menu":  {"key": KEY_S, "shift": true, "label": "Snap menu (3D cursor) — when not moving"},
		"cursor.to_selected":   {"label": "Cursor to selected"},
		"cursor.to_active":     {"label": "Cursor to active"},
		"cursor.to_grid":       {"label": "Cursor to grid"},
		"cursor.to_origin":     {"label": "Cursor to world origin"},
		# THE Q MENU and Blender's HIDE (docs/cutting-and-operations.md §9c, user
		# 2026-10-12 "Implement hide now"). Q is free in build mode (player.carve_mode
		# is suppressed there). Hide drives the Outliner's own rows, so its eye agrees.
		"builder.q_menu":          {"key": KEY_Q, "label": "Operations menu (HardOps Q)"},
		# BLENDER'S FLY MODAL (2.79 View3D Fly Modal; keymap step 1, §2a).
		"builder.fly":             {"key": KEY_F, "shift": true,
									"label": "Fly: WASD, E/Q up/down, wheel speed — Enter keeps, Esc goes back"},
		"builder.hide":            {"key": KEY_H, "label": "Hide the selection"},
		"builder.hide_unselected": {"key": KEY_H, "shift": true, "label": "Hide everything else"},
		"builder.unhide_all":      {"key": KEY_H, "alt": true, "label": "Unhide all"},
		# Q-menu rows with no key of their own (menu-only, like cursor.*).
		"builder.make_real":         {"label": "Make Real (bake the shape and its cuts)"},
		# E on a shape piece (#109, user 2026-10-13: "ask to make real or cancel"): the
		# prompt's two rows. The pieces are read-only; Make Real first, then Extrude.
		"builder.extrude_make_real": {"label": "Make Real, then extrude this face"},
		"builder.prompt_cancel":     {"label": "Cancel"},
		# A shape's part edited (#109 "select, prompt on edit"): the guard's prompt row.
		"builder.make_real_then":    {"label": "Make Real, then go on"},
		"builder.cuts_clear":        {"label": "Clear the cuts (on it / made with it)"},
		"builder.cut_as_subtract":   {"label": "Cutter: subtract"},
		"builder.cut_as_intersect":  {"label": "Cutter: intersect"},
		"builder.cut_as_slice":      {"label": "Cutter: slice (keep both halves)"},
		"builder.select_cut_target": {"label": "Select what this cutter cuts"},
		# The Knife is MODAL now: C takes it up, X/Y/Z pick the axis inside it, Enter
		# cuts, Escape cancels with nothing written. Shift+C is GONE — Shift is
		# sprint, so the old chord meant that while sprinting you got the horizontal
		# cut whether you wanted it or not. Variants belong inside an operator, not
		# on a modifier the movement system already owns.
		# OPERATORS ARE UNMODIFIED LETTERS. Movement owns Shift and Ctrl in build mode,
		# so a chord would mean the tool you get depends on whether you were sprinting.
		# Variants (axis, distance, size) live INSIDE each operator.
		# KEYMAP STEP 3: Blender's keys now that WASD no longer fly (step 1) — K knife,
		# Shift+D duplicate, S scale.
		"builder.knife":        {"key": KEY_K, "label": "Knife: cut the aimed brush"},
		"builder.move":         {"key": KEY_G, "label": "Move the aimed brush"},
		"builder.duplicate":    {"key": KEY_D, "shift": true, "label": "Duplicate the selection and move it"},
		"builder.duplicate_linked": {"key": KEY_D, "alt": true, "label": "Duplicate linked: the copy shares the shape's data (edit one, both change)"},
		"builder.rotate":       {"key": KEY_R, "label": "Rotate the selection (X Y Z axis, [ ] or wheel)"},
		"builder.scale":        {"key": KEY_S, "label": "Scale the selection (X Y Z axis, C pivot, [ ] or wheel)"},
		# Blender's transform.mirror (#109): Ctrl+M, then X / Y / Z, Enter.
		"builder.mirror":       {"key": KEY_M, "ctrl": true, "label": "Mirror the selection (X Y Z axis, about the pivot)"},
		# Blender's pair: parent the selected objects to the ACTIVE one; clear keeps them
		# where they are in the world.
		"builder.parent":       {"key": KEY_P, "ctrl": true, "label": "Parent the selected objects to the active one"},
		"builder.clear_parent": {"key": KEY_P, "alt": true, "label": "Clear the parent (keep the world position)"},
		# OBJECTS AND COLLECTIONS (#120 F1, Blender 2.79's keys: tools/keymap/blender_279_left.json).
		# A brush object is a group of brushes; a collection holds objects and collections.
		"builder.collection_new":    {"key": KEY_G, "ctrl": true, "label": "New collection from the selection"},
		# #120 L4: menu-only (user 2026-10-19), Object menu.
		"builder.save_asset":        {"label": "Save collection as asset…"},
		"builder.place_asset":       {"label": "Asset: place the current one at the 3D cursor"},
		"builder.collection_move":   {"key": KEY_M, "label": "Move to collection…"},
		"builder.collection_link":   {"key": KEY_M, "shift": true, "label": "Link to collection… (also)"},
		"builder.collection_remove": {"key": KEY_G, "ctrl": true, "alt": true, "label": "Remove from collection…"},
		"builder.join":              {"key": KEY_J, "ctrl": true, "label": "Join the selected brushes into the active one's object"},
		"builder.separate":          {"key": KEY_P, "label": "Separate the selected brushes into a new object"},
		"builder.rename":            {"key": KEY_F2, "label": "Rename the selected collection (outliner)"},
		# Menu-only (Object menu, Q menu): any brush -> a parametric shape (#120 F2).
		"builder.make_parametric":   {"label": "Make parametric (the brush becomes a shape: box or hull)"},
		"builder.add_curve":         {"label": "Curve (place points on surfaces; for cables, follow-curve)"},
		"builder.add_cable":         {"label": "Cable (place points on surfaces; [ ] slack hangs each span)"},
		"builder.edit_curve":        {"label": "Edit curve (the aimed point: move, Tab, X delete, I insert)"},
		# #120 L3 §14.3 (Cablerator's Drop the Cable). Ctrl+Shift+D: with Shift+D / Alt+D
		# beside it, and free in Blender's 3D view.
		"builder.drop_cable":        {"key": KEY_D, "ctrl": true, "shift": true, "label": "Drop the selected cables to the ground (ends stay)"},
		# #120 L3 §14.5. Alt+C: Blender 2.79's Convert (to Curve from Mesh), the nearest act.
		"builder.cable_from_edges":  {"key": KEY_C, "alt": true, "label": "Cable from edges (edge mode: a cable per chain of selected edges)"},
		# #120 L3 §14.6. Shift+Ctrl+C, beside Alt+C (Blender 2.79's is Add Constraint with
		# targets; there are no constraints here).
		"builder.cable_bundle":      {"key": KEY_C, "ctrl": true, "shift": true, "label": "Cable bundle between two face sets (face mode: two islands)"},
		"builder.assign_generator":  {"label": "Generator… (stairs, tunnel … on this shape; it stays the passage)"},
		"builder.add_modifier":      {"key": KEY_M, "ctrl": true, "shift": true, "label": "Add modifier… (mirror, array, grid, radial, curve, chamfer, dice on this shape)"},
		# Menu-only, as in Blender (the outliner's / X menu's Delete Hierarchy): X keeps children.
		"builder.delete_hierarchy":  {"label": "Delete hierarchy (the objects and everything under them)"},
		"builder.extrude":      {"key": KEY_E, "label": "Extrude the aimed face"},
		# The create tool: from Shift+A (the add menu, one row per shape) and the Tools
		# column — N is Blender's sidebar since keymap step 3.
		"builder.create_box":   {"label": "New shape on the aimed surface (create tool)"},
		"builder.add_menu":     {"key": KEY_A, "shift": true, "label": "Add menu (every shape type)"},
		# The add menu's rows: the create tool, already set to that shape (param_shapes ids).
		"builder.add_box":           {"label": "Box"},
		"builder.add_stairs":        {"label": "Stairs"},
		"builder.add_spiral_stairs": {"label": "Spiral stairs"},
		"builder.add_tunnel":        {"label": "Arch / tunnel"},
		"builder.add_cylinder":      {"label": "Cylinder"},
		"builder.add_cone":          {"label": "Cone"},
		"builder.add_sphere":        {"label": "Sphere"},
		# THE U MENU (keymap step 3; Blender's U = the UV menu): every texture command, each
		# also on its own key (user: "in a menu AND accessible via hotkey").
		"builder.uv_menu":       {"key": KEY_U, "label": "Texture menu (apply, pick, hotspot, fit ...)"},
		"builder.texture_apply": {"key": KEY_U, "ctrl": true, "label": "Apply the current texture to the selection"},
		"builder.texture_pick":  {"key": KEY_U, "alt": true, "label": "Pick the aimed face's texture (eyedropper)"},
		"builder.confirm":      {"key": KEY_ENTER, "label": "Confirm the active operator"},
		# Editor redesign E3 (docs/editor-redesign.md §5): search every command by name.
		# SPACE since keymap step 3 — Blender_27x.py's spacebar_action='SEARCH' (Space was
		# fly-up until step 1).
		"builder.palette":      {"key": KEY_SPACE, "label": "Search every command (palette)"},
		# Blender's sidebar (N) and toolbar (T). T is global chat (toggle_chat), so the
		# toolbar toggle takes Ctrl+T.
		"builder.toggle_sidebar": {"key": KEY_N, "label": "Show / hide the right column (sidebar)"},
		"builder.toggle_toolbar": {"key": KEY_T, "ctrl": true, "label": "Show / hide the left column (tools)"},
		# #124 B3: panels moved with their header's right-click "Move to ▸" go home.
		"builder.reset_panels": {"key": KEY_N, "ctrl": true, "alt": true, "label": "Reset panel layout (every panel back to its column)"},
		# #120 L2 §13: BoxCutter's own start key (its manual: "alt + w with cursor in 3d view").
		"builder.box_cutter":   {"key": KEY_W, "alt": true, "label": "BoxCutter: draw a cutter on the aimed surface"},
		# #120 L2 §13.4–13.5: the Q menu's stack operations (StackOps), each with a key.
		"builder.change_cut":   {"key": KEY_X, "alt": true, "label": "Change cut… (subtract / slice / intersect / inset / outset)"},
		"builder.extract_cut":  {"key": KEY_Y, "alt": true, "label": "Extract… (a cut's target ∩ cutter becomes new shapes)"},
		"builder.apply_up_to":  {"key": KEY_A, "ctrl": true, "alt": true, "label": "Apply up to here… (bake the cuts up to one, keep the rest live)"},
		# Stack scroll (HardOps' modifier_scroll): a LOCAL preview of the first k cuts.
		"builder.stack_scroll_back": {"key": KEY_BRACKETLEFT, "ctrl": true, "alt": true, "label": "Stack scroll: preview one cut fewer"},
		"builder.stack_scroll_fwd":  {"key": KEY_BRACKETRIGHT, "ctrl": true, "alt": true, "label": "Stack scroll: preview one cut more"},
		"builder.adjust_last":  {"key": KEY_F6, "label": "Adjust Last (the last operation's settings)"},
		"builder.history":      {"key": KEY_Z, "ctrl": true, "alt": true, "label": "Undo History"},
		"builder.repeat_last":  {"key": KEY_R, "shift": true, "label": "Repeat the last command (starts the tool again)"},
		"builder.element_toggle": {"key": KEY_TAB, "label": "Element mode ↔ brush mode"},
		# Blender 2.79 Mesh: Ctrl+Tab = VIEW3D_MT_edit_mesh_select_mode (#109 step 6).
		"builder.select_mode_menu": {"key": KEY_TAB, "ctrl": true, "label": "Select mode menu (vertex / edge / face / brush)"},
		# THE PIVOT (keymap step 6.2; Blender 2.79's , . keys). Ctrl+. (individual origins)
		# is a later item, so it is not bound to nothing.
		"builder.pivot_bbox":   {"key": KEY_COMMA, "label": "Pivot: bounding box centre"},
		"builder.pivot_median": {"key": KEY_COMMA, "ctrl": true, "label": "Pivot: median point"},
		"builder.pivot_cursor": {"key": KEY_PERIOD, "label": "Pivot: 3D cursor"},
		"builder.pivot_active": {"key": KEY_PERIOD, "alt": true, "label": "Pivot: active element"},
		"builder.pivot_individual": {"key": KEY_PERIOD, "ctrl": true, "label": "Pivot: individual origins (each about its own centre)"},
		# PROPORTIONAL EDITING (#109, Blender 2.7x): O / Shift+O / Alt+O.
		"builder.proportional":           {"key": KEY_O, "label": "Proportional editing on / off"},
		"builder.proportional_falloff":   {"key": KEY_O, "shift": true, "label": "Proportional falloff: next"},
		"builder.proportional_connected": {"key": KEY_O, "alt": true, "label": "Proportional: connected only on / off"},
		"builder.wireframe":    {"key": KEY_Z, "label": "Wireframe on / off"},
		# Blender's Shift+Space (user decision 2026-10-02). Shift+Space is fast fly-up at
		# top level for the frame it is pressed; the popup's `menu` context stops the
		# camera from then on — the same accepted trade as Shift+Z.
		"builder.tool_popup":   {"key": KEY_SPACE, "shift": true, "label": "Tools at the cursor (popup)"},
		# THE WHEEL DRIVES THE ACTIVE TOOL (user decision 2026-10-02): the same as the
		# tool's ] / [ — box size, knife offset, move steps, extrude push. Only while a
		# tool is up and the cursor is out (map_dc_level); while looking (RMB held) the
		# wheel stays camera zoom / Alt+wheel fly speed, and over a panel it scrolls it.
		# Wheel DOWN = bigger, UP = smaller: the user's direction (2026-10-02, "it should
		# be inverted" after playing with up = bigger).
		"builder.tool_more":    {"mouse": MOUSE_BUTTON_WHEEL_DOWN, "label": "Active tool: bigger / more (same as ])"},
		"builder.tool_less":    {"mouse": MOUSE_BUTTON_WHEEL_UP, "label": "Active tool: smaller / less (same as [)"},
		"builder.delete":       {"key": KEY_DELETE, "label": "Delete the selected objects, or the aimed brush"},
		# Blender 2.79: X deletes WITH a confirm (a one-row popup), Delete without (step 6.4).
		"builder.delete_menu":  {"key": KEY_X, "label": "Delete… (confirm)"},
		# CTRL+Z SINCE KEYMAP STEP 3. It was plain Z because Ctrl was DESCEND whenever you
		# were in build mode, and undoing dropped you through the floor (descend is
		# polled, so nothing could intercept it). Step 1 made Ctrl descend ONLY while
		# flying, and the exclusive `fly` context suppresses every command then — so
		# Blender's Ctrl+Z is safe. Plain Z is Blender's wireframe toggle.
		#
		# ⚠ While an operator is up, X/Y/Z are ITS axis keys and it gets first refusal.
		"builder.undo":         {"key": KEY_Z, "ctrl": true, "label": "Undo my last edit"},
		"builder.redo":         {"key": KEY_Z, "ctrl": true, "shift": true, "label": "Redo"},
	},
	# UV CONTROLS on the selection (docs/uv-controls-design.md). Arrows move the texture
	# one grid step the way they point ON SCREEN (TrenchBroom's camera mapping); Home /
	# End always step to the NEXT fit / edge, Shift to the previous (TrenchBroom).
	"face_sel": {
		"builder.uv_left":     {"key": KEY_LEFT, "label": "Move texture left (as you see it, one grid step)"},
		"builder.uv_right":    {"key": KEY_RIGHT, "label": "Move texture right (as you see it, one grid step)"},
		"builder.uv_up":       {"key": KEY_UP, "label": "Move texture up (as you see it, one grid step)"},
		"builder.uv_down":     {"key": KEY_DOWN, "label": "Move texture down (as you see it, one grid step)"},
		"builder.uv_rot_ccw":  {"key": KEY_PAGEUP, "label": "Rotate texture +15°"},
		"builder.uv_rot_cw":   {"key": KEY_PAGEDOWN, "label": "Rotate texture -15°"},
		"builder.uv_rot_ccw_fine": {"key": KEY_PAGEUP, "shift": true, "label": "Rotate texture +1°"},
		"builder.uv_rot_cw_fine":  {"key": KEY_PAGEDOWN, "shift": true, "label": "Rotate texture -1°"},
		"builder.uv_fit":      {"key": KEY_HOME, "label": "Fit texture to the face (again: next fit)"},
		"builder.uv_align":    {"key": KEY_END, "label": "Align texture to a face edge (again: next edge)"},
		"builder.uv_fit_prev":   {"key": KEY_HOME, "shift": true, "label": "Fit texture: previous fit"},
		"builder.uv_align_prev": {"key": KEY_END, "shift": true, "label": "Align texture: previous edge"},
		# HOTSPOT (#94 P2, docs/hotspot-trim-design.md §6): fit each selected face to the best
		# rect of its trim sheet; re-roll picks another of the near-equal rects.
		# Shift+U since 2026-10-12: Shift+F is Blender's fly (keymap step 1).
		"builder.uv_hotspot":  {"key": KEY_U, "shift": true, "label": "Hotspot: fit each face to its trim sheet"},
		# Shift+Alt+U since keymap step 3: Shift+R is Blender's repeat-last.
		"builder.uv_reroll":   {"key": KEY_U, "shift": true, "alt": true, "label": "Hotspot: re-roll the rect choice"},
		# Ctrl+Shift+U (#109 build tools): the family's third key — a command in a menu
		# needs a key too. Free in Blender 2.7x (Window Ctrl+U, Screen Ctrl+Alt+U only).
		"builder.uv_pin":      {"key": KEY_U, "ctrl": true, "shift": true, "label": "Hotspot: pin the rect each face shows now"},
	},
	# THE NUMBER ROW, WHICH RULE 2 FORBIDS — and this is the exception that proves the
	# context system works rather than a hole in it. `1`-`9` are weapon slots, so no
	# TOOL may take them; but `build_mode` is EXCLUSIVE and suppresses `player`, so
	# while building there are no weapons to conflict with. That is precisely what
	# Blender does (1/2/3 select vertex/edge/face in Edit Mode and mean something else
	# outside it), and precisely what an exclusive context is for. Put them in
	# `builder` instead and they WOULD collide, because `builder` is additive and stays
	# live during play on the level map.
	#
	# 1/2/3 keep Blender's order exactly, so the muscle memory transfers; `4` is the
	# brush itself, which is Blender's Object Mode and has no number of its own there.
	"build_mode": {
		"build_mode.sel_vertex": {"key": KEY_1, "label": "Select mode: vertex"},
		"build_mode.sel_edge":   {"key": KEY_2, "label": "Select mode: edge"},
		"build_mode.sel_face":   {"key": KEY_3, "label": "Select mode: face"},
		"build_mode.sel_brush":  {"key": KEY_4, "label": "Select mode: whole brush"},
		# CSG — PLAIN LETTERS, and the first attempt at these got it wrong in a way
		# worth recording, because rule 1 and the operator rule contradict each other
		# in build mode and rule 1 loses.
		#
		# These shipped as Ctrl+D / Ctrl+I / Ctrl+M, reasoning that CSG is an editor
		# command and rule 1 reserves Ctrl+letter for those. But `Ctrl` is DESCEND
		# while flying, so every CSG op also dropped you through the floor. And it
		# cannot be fixed by consuming the event: descend is
		# `Input.is_action_pressed("crouch")` POLLED in the physics step
		# (player_procedural.gd:1892), not an event, so no input handler can intercept
		# it. A held modifier that movement polls is unusable as a command binding, and
		# that is a property of polling rather than of these three keys.
		#
		# So: build mode owns the plain letters movement does not (W A S D are out),
		# exactly as §3 of docs/build-mode-operators.md says — "movement owns the
		# modifiers, operators are unmodified letters". H for hole, I for intersect,
		# J for join, which is Blender's Ctrl+J and the same idea.
		#
		# ⚠ STALE UNTIL NOW, and it read as an instruction: builder.undo is PLAIN Z
		# (see its binding above), not Ctrl+Z. It was moved precisely because Ctrl is
		# descend and undoing while flying also descended — keymap_gate asserts
		# "Ctrl+Z now does NEITHER". _delete_aimed's message still said "Ctrl+Z to
		# bring it back" long after that, so the product surface was telling people
		# to press the one chord guaranteed to do nothing.
		#
		# MERGE IS "CONVEX MERGE" IN THE LABEL AND WILL STAY THAT WAY. It is the hull
		# of both brushes' vertices, not a union — merging two separated boxes fills
		# the gap between them. Someone reaching for union and getting a hull would
		# rightly call it a bug, so the name has to carry what the geometry cannot.
		# BOOL TOOL / HARDOPS KEYS (keymap step 1, §2a): Ctrl is only "descend" while
		# FLYING now, and the `fly` context suppresses build_mode then — so a Ctrl chord
		# can no longer drop you through the floor. (H is Blender's hide; I is free for
		# inset later.)
		"build_mode.csg_subtract":  {"key": KEY_KP_SUBTRACT, "ctrl": true,
									 "label": "CSG: active MINUS the others (hole)"},
		# LIVE ONLY: needs a shape as the cutter (docs/cutting-and-operations.md §9c).
		"build_mode.csg_slice":     {"key": KEY_KP_DIVIDE, "ctrl": true,
									 "label": "CSG: slice — keep both halves (live, shape cutter)"},
		"build_mode.csg_intersect": {"key": KEY_KP_MULTIPLY, "ctrl": true,
									 "label": "CSG: active INTERSECT the others"},
		"build_mode.csg_merge":     {"key": KEY_J,
									 "label": "CSG: convex merge — a HULL, not a union"},
		# Menu-only since Alt+H became Blender's unhide all (§9c).
		"build_mode.csg_discard":   {"label": "CSG: discard the cutters afterwards (on / off)"},
		# FACES -> NEW BRUSHES (2026-10-05). J is hull (merge), E is extrude; Alt makes
		# them act on the selected FACES rather than brushes.
		"build_mode.faces_hull":    {"key": KEY_J, "alt": true,
									 "label": "New brush: hull of the selected faces"},
		"build_mode.faces_extrude": {"key": KEY_E, "alt": true,
									 "label": "New brushes: extrude each selected face one grid step"},
	},
	# INSIDE THE FLY MODAL (Shift+F; Blender 2.79 View3D Fly Modal, §2a). WASD are the
	# movement actions (polled, as in RMB-look); these are the modal's own. Only live while
	# the `fly` context is on, which suppresses every editor key.
	"fly": {
		# POSITION, like WASD (§6): up / down sit beside the movement keys on any layout.
		"fly.up":          {"key": KEY_E, "hold": true, "by": "position", "label": "Fly up"},
		"fly.down":        {"key": KEY_Q, "hold": true, "by": "position", "label": "Fly down"},
		"fly.confirm":     {"key": KEY_ENTER, "label": "Keep this view (also LMB / Space)"},
		"fly.confirm_alt": {"key": KEY_SPACE, "label": "Keep this view"},
		"fly.cancel":      {"key": KEY_ESCAPE, "label": "Fly back to where you started (also RMB)"},
	},
}

## The context STACK, innermost last. Order matters: an exclusive context suppresses
## the non-`always` contexts BELOW it, so pushing "builder" over "player" silences
## the player without either of them knowing about the other.
var _stack: Array[String] = ["player"]
var _overrides: Dictionary = {}          # action -> binding, from disk


## KEY TRACE (HYPER_TPS_KEYTRACE=1): for an intermittent "the key did nothing".
## Logs every key press as it ENTERS the tree — with the GUI focus owner, the context
## stack and the mouse mode — plus each action tapped() matches, and each action the
## key would have matched but whose context was inactive. The map already prints when
## an operator activates, so a key's whole path is in the log. Off = no cost.
var _keytrace := OS.get_environment("HYPER_TPS_KEYTRACE") == "1"


## Sees every key FIRST: Godot calls _input from the root's LAST child backwards, so
## this keeps itself the last child (an autoload would see keys only after the game).
class _KeyTracer:
	extends Node
	var km: Node = null

	func _ready() -> void:
		process_mode = Node.PROCESS_MODE_ALWAYS
		get_viewport().gui_focus_changed.connect(func(c: Control) -> void:
			print("[KeyTrace] focus -> %s" % (str(c.get_path()) if c != null else "none")))

	func _process(_d: float) -> void:
		var p := get_parent()
		if p != null and p.get_child(p.get_child_count() - 1) != self:
			p.move_child(self, -1)

	func _input(event: InputEvent) -> void:
		if not (event is InputEventKey) or not event.pressed or event.echo:
			return
		var f: Control = get_viewport().gui_get_focus_owner()
		print("[KeyTrace] %s  focus=%s  ctx=%s  mouse=%d" % [(event as InputEventKey).as_text(),
				str(f.get_path()) if f != null else "none", str(km.get("_stack")), Input.mouse_mode])


func _ready() -> void:
	if _keytrace:
		var t := _KeyTracer.new()
		t.name = "KeyTracer"
		t.km = self
		get_tree().root.add_child.call_deferred(t)
		print("[KeyTrace] ON (HYPER_TPS_KEYTRACE=1)")
	_load_overrides()
	register_all()
	for c: String in conflicts():
		# A WARNING, not a failure: the same key meaning different things in
		# different contexts is correct, and only becomes a bug when two bindings in
		# the SAME context collide. That is what this reports.
		push_warning("[Keymap] %s" % c)
	process_mode = Node.PROCESS_MODE_ALWAYS


## FOCUS IS POLLED, not signalled: gui_focus_changed fires when a control GAINS focus
## but not when focus is released to nothing (release_focus, a click on empty space),
## and a text_input context that never popped would freeze every tool. One focus-owner
## read per frame is the whole cost.
func _process(_delta: float) -> void:
	var vp := get_viewport()
	if vp == null:
		return
	var typing := _is_text_field(vp.gui_get_focus_owner())
	if typing != _stack.has("text_input"):
		if typing:
			push_context("text_input")
		else:
			pop_context("text_input")


static func _is_text_field(c: Control) -> bool:
	if c == null or not c.is_visible_in_tree():
		return false
	if c is LineEdit:
		return (c as LineEdit).editable
	if c is TextEdit:
		return (c as TextEdit).editable
	return false


## May movement read the keyboard? False while a text field has focus — the keys are
## words, not steps. (Movement is POLLED, so contexts alone cannot stop it.)
func movement_allowed() -> bool:
	return not _stack.has("text_input") and not _stack.has("menu")


## Is the camera FLYING right now — RMB held, or the Shift+F fly modal (keymap step 1,
## docs/keymap-blender-279.md §2a)? The build-mode camera moves only then.
func flying() -> bool:
	return movement_allowed() and is_context_active("fly")


## Do Ctrl / Shift / Space belong to the fly camera (down / fast / up) right now? ONLY
## while flying (§2a): the `fly` context is exclusive, so a tool or a command never
## competes for them — the old "a tool owns them unless RMB is held" rule is this rule.
func fly_modifiers_allowed() -> bool:
	return flying()


# ---- the Shortcut bridge (editor redesign E0, §1) -----------------------------

var _shortcuts: Dictionary = {}   # action -> Shortcut


## ONE Shortcut per action, shared by every surface (toolbar button, menu item,
## palette). Its event is an InputEventAction, which matches whatever key the action
## is bound to in InputMap and prints THAT key — so a rebind (which re-registers the
## InputMap event) updates every tooltip and accelerator with no extra code. Godot's
## own editor shares one Ref<Shortcut> per command for the same reason.
func shortcut_of(action: String) -> Shortcut:
	if _shortcuts.has(action):
		return _shortcuts[action]
	var sc := Shortcut.new()
	var b := binding_of(action)
	sc.resource_name = String(b.get("label", LABELS.get(action, action)))
	var ev := InputEventAction.new()
	ev.action = action
	ev.pressed = true
	sc.events = [ev]
	_shortcuts[action] = sc
	return sc


## Run an action as if its key were pressed — for menus, buttons and the palette. It
## goes through normal input dispatch, so the SAME handler (Keymap.tapped in the map's
## input code) runs: one code path for a key, a click and a search.
##
## It replays the action's BOUND KEY, not an InputEventAction: map_dc_level dispatches
## in _unhandled_KEY_input, which never sees a non-key event (an InputEventAction
## replay reached nothing — gate_editor_e0 caught it). Replaying the key also keeps
## every rule a key press obeys, e.g. an active operator's first refusal.
func run(action: String) -> void:
	var b := binding_of(action)
	# MENU-ONLY actions (no key) take the action path too — the key path would replay a
	# keycode-0 key that matches nothing (gate_editor_cursor C6).
	if b.is_empty() or b.has("mouse") or not b.has("key"):
		for pressed: bool in [true, false]:
			var ea := InputEventAction.new()
			ea.action = action
			ea.pressed = pressed
			Input.parse_input_event(ea)
		return
	for pressed: bool in [true, false]:
		var ev := InputEventKey.new()
		ev.physical_keycode = int(b.get("key", 0)) as Key
		ev.keycode = ev.physical_keycode
		ev.ctrl_pressed = bool(b.get("ctrl", false))
		ev.shift_pressed = bool(b.get("shift", false))
		ev.alt_pressed = bool(b.get("alt", false))
		ev.pressed = pressed
		Input.parse_input_event(ev)


# ---- registration -----------------------------------------------------------

func register_all() -> void:
	for context: String in DEFAULTS:
		for action: String in (DEFAULTS[context] as Dictionary):
			_register(action, binding_of(action))


func _register(action: String, b: Dictionary) -> void:
	if b.is_empty():
		return
	if not InputMap.has_action(action):
		InputMap.add_action(action)
	InputMap.action_erase_events(action)
	# MENU-ONLY actions (no key, no button — e.g. the 3D cursor's snap commands, run from
	# the Shift+S menu): the action exists with NO event. A keyless InputEventKey would
	# be physical keycode 0, which matches events nothing meant to bind.
	if not b.has("key") and not b.has("mouse"):
		return
	if b.has("mouse"):
		var mb := InputEventMouseButton.new()
		mb.button_index = int(b["mouse"])
		# Modifiers too (Shift+RMB places the 3D cursor); exact-match tapped() needs them.
		mb.ctrl_pressed = bool(b.get("ctrl", false))
		mb.shift_pressed = bool(b.get("shift", false))
		mb.alt_pressed = bool(b.get("alt", false))
		InputMap.action_add_event(action, mb)
		return
	var ev := InputEventKey.new()
	# KEYBOARD LAYOUTS (keymap step 5, docs/keymap-blender-279.md §6). A LETTER command
	# matches the CHARACTER the layout types (Blender: GHOST_SystemX11.cc 1050-1097 takes
	# the KEYSYM) — on AZERTY "G for grab" is where the layout puts G. Everything else
	# matches the key's POSITION: the number row (Blender forces X11 keycodes 10-19 to
	# digits, so AZERTY's & é " ' still selects modes 1-4), numpad, [ ] - = (they move or
	# vanish on many layouts), and whatever a binding marks `by: "position"` (the fly
	# modal's E / Q, like WASD — where we deliberately differ from Blender, whose fly keys
	# go by character and scatter on AZERTY).
	if is_positional(int(b.get("key", 0)), b):
		ev.physical_keycode = int(b.get("key", 0))
	else:
		ev.keycode = int(b.get("key", 0))
	ev.ctrl_pressed = bool(b.get("ctrl", false))
	ev.shift_pressed = bool(b.get("shift", false))
	ev.alt_pressed = bool(b.get("alt", false))
	InputMap.action_add_event(action, ev)


## The layout rule (§6) lives in KeyRules (autoloads/key_rules.gd) so operators and
## --script gates, which compile before this autoload exists, share it.
static func is_positional(key: int, b: Dictionary = {}) -> bool:
	return KeyRules.is_positional(key, b)


static func key_of(e: InputEventKey) -> int:
	return KeyRules.key_of(e)


## The binding in force for an action: the player's override if any, else default.
func binding_of(action: String) -> Dictionary:
	if _overrides.has(action):
		return _overrides[action]
	for context: String in DEFAULTS:
		var m: Dictionary = DEFAULTS[context]
		if m.has(action):
			return m[action]
	return {}


func context_of(action: String) -> String:
	for context: String in DEFAULTS:
		if (DEFAULTS[context] as Dictionary).has(action):
			return context
	# Pre-existing actions: bindings in project.godot, membership here.
	return String(MEMBERSHIP.get(action, ""))


# ---- contexts ---------------------------------------------------------------

## Enter a context. Re-pushing one that is already on the stack MOVES it to the top
## rather than duplicating it, so a map that is reloaded cannot leave a stale copy
## underneath and then pop the wrong one.
##
## OWNED HOLDS (2026-10-05). A context with an `owner` stays on the stack while ANY
## owner still holds it. Without this a MAP CHANGE killed every builder key: maps push
## `builder` in _ready and pop it in _exit_tree, and game.change_map() queue_frees the
## old map and adds the new one at once — so the old map's pop ran at the END of the
## frame, AFTER the new map's push, and took `builder` with it (N, Shift+Space, Delete
## dead until the next map load; mouse picks, outside the keymap, still worked).
## Reproduced and gated by scenes/test/gate_map_change_keys.gd. With no owner the
## behaviour is exactly the old one: one anonymous holder, one pop removes it.
var _holders: Dictionary = {}          # context -> {owner instance id (0 = none): true}


func push_context(context: String, owner: Object = null) -> void:
	if not CONTEXTS.has(context):
		push_warning("[Keymap] unknown context %s" % context)
		return
	var k: int = owner.get_instance_id() if owner != null else 0
	if not _holders.has(context):
		_holders[context] = {}
	(_holders[context] as Dictionary)[k] = true
	_stack.erase(context)
	_stack.append(context)


func pop_context(context: String, owner: Object = null) -> void:
	var k: int = owner.get_instance_id() if owner != null else 0
	if _holders.has(context):
		var h: Dictionary = _holders[context]
		h.erase(k)
		if not h.is_empty():
			return                         # someone else still holds it
		_holders.erase(context)
	_stack.erase(context)


## Is this context reachable right now?
##
## "" means an action nobody assigned a context to — active, because the alternative
## is silently swallowing input from code that predates this layer.
func is_context_active(context: String) -> bool:
	if context == "":
		return true
	if bool((CONTEXTS.get(context, {}) as Dictionary).get("always", false)):
		return true
	var at: int = _stack.rfind(context)
	if at < 0:
		return false
	# A context that only exists INSIDE another (builder inside build_mode).
	var req: String = String((CONTEXTS.get(context, {}) as Dictionary).get("requires", ""))
	if req != "" and not is_context_active(req):
		return false
	# Anything exclusive ABOVE it wins — unless that context explicitly keeps this one.
	for i in range(at + 1, _stack.size()):
		var above: Dictionary = CONTEXTS.get(_stack[i], {})
		if not bool(above.get("exclusive", false)):
			continue
		var keeps: Array = above.get("keeps", [])
		if not keeps.has(context):
			return false
	return true


## The stack, top last — for a debug overlay and for the gate.
func active_contexts() -> Array[String]:
	var out: Array[String] = []
	for c: String in _stack:
		if is_context_active(c):
			out.append(c)
	return out


# ---- dispatch ---------------------------------------------------------------

## A discrete press. EXACT by construction: an action bound to plain Z must not
## fire on Ctrl+Z. Use this for every toggle and shortcut.
func tapped(event: InputEvent, action: String) -> bool:
	if not is_context_active(context_of(action)):
		# KEYTRACE: the key IS this action's, but its context is not active right now
		# — the case that reads as "the key sometimes does nothing".
		if _keytrace and event.is_action_pressed(action, false, not _any_mods(action)):
			print("[KeyTrace]   ~ %s: key matches, context '%s' NOT active (stack %s)"
					% [action, context_of(action), str(_stack)])
		return false
	var hit := event.is_action_pressed(action, false, not _any_mods(action))
	if hit and _keytrace:
		print("[KeyTrace]   -> %s" % action)
	return hit


## The key-up of a hold action — the other half of builder.fill.
func released(event: InputEvent, action: String) -> bool:
	if not is_context_active(context_of(action)):
		return false
	return event.is_action_released(action, not _any_mods(action))


func _any_mods(action: String) -> bool:
	return bool(binding_of(action).get("any_mods", false))


## A continuous hold, polled rather than event-driven. NON-exact on purpose:
## movement has to keep working while a modifier is down.
func held(action: String) -> bool:
	if not is_context_active(context_of(action)):
		return false
	return Input.is_action_pressed(action)


# ---- rebinding --------------------------------------------------------------

## Rebind an action. Returns "" on success, or the reason it was refused — a
## caller (a settings screen) needs to be able to say why rather than silently
## keeping the old key.
func rebind(action: String, key: int, ctrl := false, shift := false, alt := false) -> String:
	if binding_of(action).is_empty():
		return "unknown action %s" % action
	var b: Dictionary = {"key": key, "ctrl": ctrl, "shift": shift, "alt": alt}
	var old: Dictionary = binding_of(action)
	if old.has("hold"):
		b["hold"] = old["hold"]
	if old.has("label"):
		b["label"] = old["label"]
	var clash := _clash_within_context(action, b)
	if clash != "":
		return "that key is already %s" % clash
	_overrides[action] = b
	_register(action, b)
	_save_overrides()
	return ""


func reset_binding(action: String) -> void:
	_overrides.erase(action)
	_register(action, binding_of(action))
	_save_overrides()


## Every binding in force, declared here or in project.godot, for a settings screen
## or the help overlay. Grouped by context, and INCLUDING the pre-existing actions —
## a key list that showed only the new bindings would be worse than none.
func bindings() -> Array:
	var out: Array = []
	for context: String in DEFAULTS:
		for action: String in (DEFAULTS[context] as Dictionary):
			var b: Dictionary = binding_of(action)
			out.append({
				"action": action, "context": context,
				"label": String(b.get("label", "")),
				"keys": describe(action),
			})
	for action: String in MEMBERSHIP:
		out.append({
			"action": action, "context": String(MEMBERSHIP[action]),
			"label": String(LABELS.get(action, action.capitalize())),
			"keys": describe(action),
		})
	return out


## Readable names for the pre-existing actions, since an action id is not a label.
const LABELS: Dictionary = {
	"move_forward": "Move forward", "move_back": "Move back",
	"move_left": "Strafe left", "move_right": "Strafe right",
	"jump": "Jump", "crouch": "Crouch", "sprint": "Sprint", "walk": "Walk",
	"grapple": "Grapple", "toggle_light": "Toggle flashlight",
	"toggle_chat": "Chat", "ui_cancel": "Release mouse / back out",
}


func _describe_event(k: InputEventKey) -> String:
	var parts: Array[String] = []
	if k.ctrl_pressed:
		parts.append("Ctrl")
	if k.shift_pressed:
		parts.append("Shift")
	if k.alt_pressed:
		parts.append("Alt")
	parts.append(OS.get_keycode_string(
		k.physical_keycode if k.physical_keycode != 0 else k.keycode))
	return "+".join(parts)


## Human-readable binding, e.g. "Ctrl+Shift+Z".
##
## Falls back to READING THE ENGINE for actions whose binding lives in
## project.godot, so a help screen built on this cannot silently omit movement —
## the most important half of the key list is exactly the half this file does not
## own.
func describe(action: String) -> String:
	var b: Dictionary = binding_of(action)
	if b.is_empty():
		if not InputMap.has_action(action):
			return ""
		for ev: InputEvent in InputMap.action_get_events(action):
			if ev is InputEventKey:
				return _describe_event(ev as InputEventKey)
			if ev is InputEventMouseButton:
				return "Mouse %d" % (ev as InputEventMouseButton).button_index
		return ""
	if b.has("mouse"):
		return {MOUSE_BUTTON_LEFT: "LMB", MOUSE_BUTTON_RIGHT: "RMB", MOUSE_BUTTON_MIDDLE: "MMB",
				MOUSE_BUTTON_WHEEL_UP: "Wheel up", MOUSE_BUTTON_WHEEL_DOWN: "Wheel down"}.get(
				int(b["mouse"]), "Mouse %d" % int(b["mouse"]))
	var parts: Array[String] = []
	if bool(b.get("ctrl", false)):
		parts.append("Ctrl")
	if bool(b.get("shift", false)):
		parts.append("Shift")
	if bool(b.get("alt", false)):
		parts.append("Alt")
	parts.append(key_name(int(b.get("key", 0)), b))
	return "+".join(parts)


## What the key is CALLED on this keyboard (§6): a character binding by its character; a
## positional one by the label the OS layout prints on that key (AZERTY's number row
## reads & é " ', a German [ is Ü) — so the help never names a key the player cannot find.
static func key_name(key: int, b: Dictionary = {}) -> String:
	# The headless server has no layout ("Not supported by this display server" per call).
	if is_positional(key, b) and DisplayServer.get_name() != "headless":
		var lab := int(DisplayServer.keyboard_get_label_from_physical(key))
		if lab != 0 and lab != key:
			return OS.get_keycode_string(lab)
	return OS.get_keycode_string(key)


# ---- conflicts --------------------------------------------------------------

## Two actions in the SAME context bound to the same key+modifiers. Cross-context
## collisions are not reported: that is what contexts are for.
func conflicts() -> Array:
	var out: Array = []
	for context: String in DEFAULTS:
		var seen: Dictionary = {}
		for action: String in (DEFAULTS[context] as Dictionary):
			var sig := describe(action)
			if sig == "":
				continue
			if seen.has(sig):
				out.append("%s: %s and %s are both %s" % [context, seen[sig], action, sig])
			else:
				seen[sig] = action
	return out


func _clash_within_context(action: String, b: Dictionary) -> String:
	var context := context_of(action)
	var want := _sig_of(b)
	for other: String in (DEFAULTS.get(context, {}) as Dictionary):
		if other == action:
			continue
		if _sig_of(binding_of(other)) == want:
			return other
	return ""


func _sig_of(b: Dictionary) -> String:
	if b.has("mouse"):
		return "mouse%d" % int(b["mouse"])
	return "%d|%s%s%s" % [int(b.get("key", 0)),
		"c" if bool(b.get("ctrl", false)) else "",
		"s" if bool(b.get("shift", false)) else "",
		"a" if bool(b.get("alt", false)) else ""]


# ---- persistence ------------------------------------------------------------

func _load_overrides() -> void:
	if not FileAccess.file_exists(SAVE_PATH):
		return
	var f := FileAccess.open(SAVE_PATH, FileAccess.READ)
	if f == null:
		return
	var parsed: Variant = JSON.parse_string(f.get_as_text())
	f.close()
	if parsed is Dictionary:
		_overrides = parsed


func _save_overrides() -> void:
	var f := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
	if f == null:
		push_warning("[Keymap] cannot write %s — rebinds will not persist" % SAVE_PATH)
		return
	f.store_string(JSON.stringify(_overrides))
	f.close()