OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / command_palette.gd

234 lines · gd ref:
extends CanvasLayer
## THE COMMAND PALETTE (editor redesign E3, docs/editor-redesign.md §5.4). F3.
##
## Search every command by name — Blender's F3, Godot's Ctrl+Shift+P. Generated from
## the keymap like every other surface: each row is the action's label, the menu it
## lives in (the top bar's MENUS) and its LIVE key, so a rebind shows here with no
## code. Choosing a row runs it through Keymap.run, which replays the bound key — the
## SAME path as pressing it, so an active tool's first refusal and every context rule
## still apply. Nothing here acts on the level itself.
##
## Lists what is reachable NOW (the action's context is active when the palette opens),
## so it never offers a command that would silently do nothing. Recent picks come
## first on an empty search and are remembered per player (BuildDock prefs).

const TopBar := preload("res://scenes/game/editor_top_bar.gd")
const CONTEXTS: Array[String] = ["builder", "build_mode", "global"]
const MAX_RECENT := 8
## An empty search must list EVERY command (it is the command browser, gate_editor_e3);
## the ItemList scrolls. 60 cut the global ones off once the numpad views (17) landed.
const MAX_ROWS := 300

var map: Node = null
var _panel: PanelContainer
var _line: LineEdit
var _list: ItemList
var _entries: Array = []        # {action, label, menu, keys, hay} — rebuilt on open
var _shown: Array = []          # actions in list order
var _menu_of: Dictionary = {}   # action -> menu title


func setup(map_node: Node) -> void:
	map = map_node
	layer = 100              # over the editor bands, under the F1 key list (128)
	visible = false
	for m: Array in TopBar.MENUS:
		for a: String in m[1]:
			if a != "-" and not _menu_of.has(a):
				_menu_of[a] = String(m[0])
	var root := Control.new()
	root.set_anchors_preset(Control.PRESET_FULL_RECT)
	root.mouse_filter = Control.MOUSE_FILTER_IGNORE
	add_child(root)
	_panel = PanelContainer.new()
	_panel.set_anchors_preset(Control.PRESET_CENTER_TOP)
	_panel.custom_minimum_size = Vector2(620, 0)
	_panel.position = Vector2(-310, 90)
	root.add_child(_panel)
	var box := VBoxContainer.new()
	_panel.add_child(box)
	_line = LineEdit.new()
	_line.placeholder_text = "Search commands…"
	_line.clear_button_enabled = true
	_line.text_changed.connect(func(_t: String) -> void: _refilter())
	_line.text_submitted.connect(func(_t: String) -> void: _run_selected())
	_line.gui_input.connect(_on_line_input)
	box.add_child(_line)
	_list = ItemList.new()
	_list.custom_minimum_size = Vector2(0, 340)
	_list.focus_mode = Control.FOCUS_NONE     # typing stays in the search field
	_list.item_activated.connect(func(_i: int) -> void: _run_selected())
	_list.item_clicked.connect(func(i: int, _p: Vector2, _b: int) -> void:
		_list.select(i)
		_run_selected())
	box.add_child(_list)


func is_open() -> bool:
	return visible


func open() -> void:
	_collect()
	_line.text = ""
	visible = true
	_refilter()
	_line.grab_focus()       # -> Keymap's text_input context: tools and movement stop


func close() -> void:
	visible = false
	_line.release_focus()


func set_query(q: String) -> void:
	_line.text = q
	_refilter()


## Actions in the order shown.
func shown_actions() -> Array:
	return _shown.duplicate()


func row_text(action: String) -> String:
	for e: Dictionary in _entries:
		if e["action"] == action:
			return _row(e)
	return ""


func _row(e: Dictionary) -> String:
	var where := "   ·  %s" % e["menu"] if String(e["menu"]) != "" else ""
	return "%s%s   [%s]" % [e["label"], where, e["keys"]]


func _collect() -> void:
	_entries.clear()
	for b: Dictionary in Keymap.bindings():
		var a := String(b["action"])
		var ctx := String(b["context"])
		if a == "builder.palette" or not CONTEXTS.has(ctx) or not Keymap.is_context_active(ctx):
			continue
		# Mouse GESTURES (the wheel driving a tool) are not commands: Keymap.run would
		# replay them as an action event the viewport's mouse handler never sees.
		if Keymap.binding_of(a).has("mouse"):
			continue
		var label := String(b["label"]) if String(b["label"]) != "" else a
		var menu := String(_menu_of.get(a, ""))
		_entries.append({"action": a, "label": label, "menu": menu, "keys": String(b["keys"]),
			"hay": _norm("%s %s %s" % [label, menu, a]), "words": "%s %s %s" % [label.to_lower(), menu.to_lower(), a]})


static func _norm(s: String) -> String:
	var out := ""
	for c in s.to_lower():
		if (c >= "a" and c <= "z") or (c >= "0" and c <= "9"):
			out += c
	return out


## Higher is better; -1 = no match. Contiguous (spaces / punctuation ignored) beats
## every word present, which beats a scattered subsequence (fuzzy).
func _score(e: Dictionary, q: String) -> float:
	var qn := _norm(q)
	if qn == "":
		return 0.0
	var hay: String = e["hay"]
	var at := hay.find(qn)
	if at >= 0:
		return 1000.0 - at
	var all_words := true
	for w in q.to_lower().split(" ", false):
		if not String(e["words"]).contains(w):
			all_words = false
			break
	if all_words:
		return 500.0
	var i := 0
	var first := -1
	for j in hay.length():
		if i < qn.length() and hay[j] == qn[i]:
			if first < 0:
				first = j
			i += 1
	if i < qn.length():
		return -1.0
	return 100.0 - first * 0.1


func _refilter() -> void:
	var q := _line.text.strip_edges()
	var recent: Array = _recent()
	var scored: Array = []
	for idx in _entries.size():
		var e: Dictionary = _entries[idx]
		var s := _score(e, q)
		if s < 0.0:
			continue
		var r := recent.find(e["action"])
		if r >= 0:
			s += 50.0 * (MAX_RECENT - r) if q != "" else 10000.0 - r
		scored.append([s, idx])
	scored.sort_custom(func(x: Array, y: Array) -> bool:
		return x[0] > y[0] if x[0] != y[0] else x[1] < y[1])
	_list.clear()
	_shown.clear()
	for s: Array in scored.slice(0, MAX_ROWS):
		var e: Dictionary = _entries[int(s[1])]
		_list.add_item(_row(e))
		_shown.append(e["action"])
	if _list.item_count > 0:
		_list.select(0)


func _on_line_input(ev: InputEvent) -> void:
	if not (ev is InputEventKey) or not ev.is_pressed():
		return
	var k := (ev as InputEventKey).keycode
	if k == KEY_ESCAPE:
		close()
		_line.accept_event()
	elif k == KEY_DOWN or k == KEY_UP:
		if _list.item_count > 0:
			var cur := _list.get_selected_items()
			var i: int = (cur[0] if cur.size() > 0 else -1) + (1 if k == KEY_DOWN else -1)
			i = clampi(i, 0, _list.item_count - 1)
			_list.select(i)
			_list.ensure_current_is_visible()
		_line.accept_event()


func _run_selected() -> void:
	var sel := _list.get_selected_items()
	if sel.is_empty() or sel[0] >= _shown.size():
		return
	var action: String = _shown[sel[0]]
	close()
	_remember(action)
	# TWO frames: the search field held focus, so Keymap's text_input context is still
	# on the stack until its _process sees focus gone — a replayed key now would be
	# swallowed as typing.
	await get_tree().process_frame
	await get_tree().process_frame
	Keymap.run(action)


func _dock() -> Node:
	return (Engine.get_main_loop() as SceneTree).root.get_node_or_null("BuildDock")


func _recent() -> Array:
	var d := _dock()
	return (d.pref("palette_recent", []) as Array).duplicate() if d != null else []


func _remember(action: String) -> void:
	var d := _dock()
	if d == null:
		return
	var r := _recent()
	r.erase(action)
	r.push_front(action)
	d.set_pref("palette_recent", r.slice(0, MAX_RECENT))