OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / autoloads / help_overlay.gd

208 lines · gd ref:
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