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))