extends CanvasLayer
## THE KEY LIST, GENERATED FROM THE KEYMAP. F1.
##
## There was no way for a player to find out what any key did. The bindings existed
## in three places — project.godot, hardcoded keycode comparisons, and (now)
## autoloads/keymap.gd — so a hand-written help screen would have been wrong the
## first time someone rebound anything, and wrong again every time a key moved. It
## would also have been the FOURTH place that claims to know the bindings.
##
## So this reads Keymap.bindings() and renders it. It cannot drift: if a binding
## changes, moves context, or is rebound by the player, this screen changes with it,
## because it has no opinion of its own about what any key is.
##
## Grouped by CONTEXT, and marked with whether that context is currently reachable —
## which turns the screen into the answer to "why did that key do nothing just now",
## the question a flat key list cannot answer.
const DIM_INACTIVE := Color(1, 1, 1, 0.35)
## Keymap step 4 (docs/keymap-blender-279.md §4a): contexts under PLAIN names.
const CONTEXT_NAMES := {"global": "Always", "player": "Playing", "builder": "Editor tools",
"build_mode": "Build mode", "face_sel": "Faces selected (UV)", "tool": "Tool up",
"fly": "Flying", "text_input": "Typing", "menu": "Menu open"}
## Which Blender command each of our actions is (keymap step 2's map).
const COMMAND_MAP := "res://tools/keymap/command_map.json"
## The contexts active WHEN F1 OPENED. The search field takes focus, and focus pushes
## the exclusive `text_input` context — read live, the list would collapse to "Always"
## the moment you typed (§4a).
var _snapshot: Array[String] = []
var _show_all := false
var _show_all_box: CheckBox = null
var _blender: Dictionary = {} # action -> Blender operator idname
var _panel: PanelContainer = null
var _text: RichTextLabel = null
var _search: LineEdit = null
func _ready() -> void:
# Above gameplay UI, below nothing: it is modal-ish by attention, not by input —
# it never grabs focus, so it cannot trap the player.
layer = 128
visible = false
process_mode = Node.PROCESS_MODE_ALWAYS
_build()
func _build() -> void:
var margin := MarginContainer.new()
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 48)
margin.add_theme_constant_override("margin_right", 48)
margin.add_theme_constant_override("margin_top", 48)
margin.add_theme_constant_override("margin_bottom", 48)
add_child(margin)
_panel = PanelContainer.new()
var style := StyleBoxFlat.new()
style.bg_color = Color(0.05, 0.06, 0.08, 0.92)
style.border_color = Color(0.25, 0.9, 1.0, 0.5)
style.set_border_width_all(1)
style.set_corner_radius_all(4)
style.set_content_margin_all(20)
_panel.add_theme_stylebox_override("panel", style)
margin.add_child(_panel)
var col := VBoxContainer.new()
_panel.add_child(col)
# SEARCH (editor redesign E3b, TrenchBroom's searchable key table). It takes focus
# on open, which is the one way this overlay touches input — and it cannot trap
# you: F1 and Escape are in the `always` context, which Keymap's text_input context
# never suppresses, and both close it from inside the field.
_search = LineEdit.new()
_search.placeholder_text = "Search keys…"
_search.clear_button_enabled = true
_search.text_changed.connect(func(_t: String) -> void: _render())
_search.gui_input.connect(func(ev: InputEvent) -> void:
if ev is InputEventKey and ev.is_pressed() and (ev as InputEventKey).keycode == KEY_ESCAPE:
hide_help()
_search.accept_event())
col.add_child(_search)
# Only the keys that work now by default; this brings the rest back, dimmed (§4a).
_show_all_box = CheckBox.new()
_show_all_box.text = "Show every context"
_show_all_box.focus_mode = Control.FOCUS_NONE
_show_all_box.toggled.connect(func(on: bool) -> void: set_show_all(on))
col.add_child(_show_all_box)
_load_blender_names()
_text = RichTextLabel.new()
_text.bbcode_enabled = true
_text.scroll_active = true
_text.fit_content = false
_text.size_flags_vertical = Control.SIZE_EXPAND_FILL
col.add_child(_text)
func _unhandled_key_input(event: InputEvent) -> void:
# `global.help` is in the `always` context, so this works from inside any mode —
# a builder mode must not be able to hide the way out.
if Keymap.tapped(event, "global.help"):
toggle()
get_viewport().set_input_as_handled()
elif visible and event.is_action_pressed("ui_cancel"):
hide_help()
get_viewport().set_input_as_handled()
func toggle() -> void:
if visible:
hide_help()
else:
show_help()
func show_help() -> void:
_search.text = ""
_snapshot = Keymap.active_contexts() # BEFORE the search takes focus
_render()
visible = true
_search.grab_focus()
func hide_help() -> void:
visible = false
_search.release_focus()
func set_query(q: String) -> void:
_search.text = q
_render()
func set_show_all(on: bool) -> void:
_show_all = on
if _show_all_box != null and _show_all_box.button_pressed != on:
_show_all_box.set_pressed_no_signal(on)
_render()
func _load_blender_names() -> void:
var raw := FileAccess.get_file_as_string(COMMAND_MAP)
var d: Variant = JSON.parse_string(raw) if raw != "" else null
if d is Dictionary and (d as Dictionary).get("map") is Dictionary:
for a: String in (d["map"] as Dictionary):
_blender[a] = String((d["map"][a] as Dictionary).get("op", ""))
## The list as rendered (BBCode stripped) — for the gate.
func text() -> String:
return _text.get_parsed_text()
## Built fresh on every open, deliberately: a rebind or a context change between
## openings must be reflected, and this is far too cheap to cache.
func _render() -> void:
var by_context: Dictionary = {}
for b: Dictionary in Keymap.bindings():
var c: String = String(b["context"])
if not by_context.has(c):
by_context[c] = []
(by_context[c] as Array).append(b)
var out := "[b]KEYS — what works right now[/b] [color=#888](F1 or Esc to close)[/color]\n"
# KEYMAP STEP 4 (§4a): the contexts ACTIVE WHEN F1 OPENED, innermost first (the
# tool's keys before the editor's, the editor's before Always) — and, with "show
# every context", the rest after them, dimmed. Every context the Keymap knows, not
# a hand-kept list (face_sel, tool and fly were missing from the old one).
var snap: Array[String] = _snapshot if not _snapshot.is_empty() else Keymap.active_contexts()
var order: Array[String] = []
for i in range(snap.size() - 1, -1, -1):
order.append(snap[i])
if _show_all:
for c: String in by_context:
if not order.has(c):
order.append(c)
var words := _search.text.to_lower().split(" ", false) if _search != null else PackedStringArray()
for c: String in order:
if not by_context.has(c):
continue
var active: bool = snap.has(c)
var rows: Array = by_context[c]
rows.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return String(a["label"]) < String(b["label"]))
var block := ""
for b: Dictionary in rows:
var keys := String(b["keys"])
if keys == "":
continue # unbound: nothing useful to show
var hay := ("%s %s %s" % [String(b["label"]), keys, String(b["action"])]).to_lower()
var hit := true
for w in words:
if not hay.contains(w):
hit = false
break
if not hit:
continue
var op := String(_blender.get(String(b["action"]), ""))
var bl := (" [color=#888]· Blender: %s[/color]" % op) if op != "" else ""
var line := " [color=#4fd8ff]%-14s[/color] %s%s\n" % [keys, String(b["label"]), bl]
block += line if active else "[color=#777]%s[/color]" % line
if block == "":
continue # a search that empties a context hides its header
out += "\n[b]%s[/b] %s\n" % [
String(CONTEXT_NAMES.get(c, c)).to_upper(),
"" if active else "[color=#888](not active right now)[/color]",
] + block
_text.text = out