extends Node
## AN ACTION POPUP (editor redesign E3, docs/editor-redesign.md §5.3 / §5.5): titled
## sections of keymap actions, each row showing its LIVE key. Two instances on the
## level map:
## - the TOOL POPUP (Shift+Space, Blender's): the toolbar's tools at the cursor;
## - the CONTEXT MENU (RMB tap): what fits the selection.
## Pick by click or by pressing the action's key; either way the popup closes and the
## action runs through Keymap.run — the same path as the key itself.
##
## While it is open the keymap's `menu` context holds: no builder key fires underneath
## and the fly camera stops, so picking never also flies you somewhere.
var last_run := "" # the action the last pick handed to Keymap.run (gate)
var _menu: PopupMenu
var _actions: Array[String] = [] # by item id
var _calls: Dictionary = {} # item id -> Callable, for open_choices rows
func setup(title: String) -> void:
_menu = PopupMenu.new()
_menu.name = title
add_child(_menu)
_menu.id_pressed.connect(func(id: int) -> void:
if _calls.has(id):
choose(id)
else:
pick(_actions[id]))
_menu.popup_hide.connect(func() -> void: Keymap.pop_context("menu"))
_menu.window_input.connect(_on_window_input)
func is_open() -> bool:
return _menu.visible
## sections = [[title, [[action, text-or-""], ...]], ...]; "" text = the keymap label.
## Empty sections are skipped.
func open_sections(sections: Array, at: Vector2) -> void:
_menu.clear()
_actions.clear()
_calls.clear()
for sec: Array in sections:
var rows: Array = sec[1]
if rows.is_empty():
continue
_menu.add_separator(String(sec[0]))
for r: Array in rows:
var action := String(r[0])
var text := String(r[1]) if r.size() > 1 and String(r[1]) != "" \
else String(Keymap.binding_of(action).get("label", action))
_actions.append(action)
_menu.add_item(text, _actions.size() - 1)
var idx := _menu.item_count - 1
if r.size() > 2:
_menu.set_item_tooltip(idx, String(r[2]))
_menu.set_item_accelerator(idx, _accel(action)) # display: the live key
Keymap.push_context("menu")
_menu.popup(Rect2i(Vector2i(at), Vector2i.ZERO))
## CHOICES rather than actions: rows = [[text, Callable], ...] under one title — a list
## the keymap cannot know in advance (M's "move to which collection"). Picking runs the
## Callable once the popup has closed, like pick().
## A row may name a KEY as its third element (#120 L1 §12.5: Shift+Ctrl+M, then a letter
## adds that modifier): shown as the row's accelerator, and that key, unmodified, picks it.
var _choice_keys: Dictionary = {} # keycode -> choice id
func open_choices(title: String, rows: Array, at: Vector2) -> void:
_menu.clear()
_actions.clear()
_calls.clear()
_choice_keys.clear()
_menu.add_separator(title)
for r: Array in rows:
_actions.append("")
var id := _actions.size() - 1
_calls[id] = r[1]
_menu.add_item(String(r[0]), id)
if r.size() > 2 and int(r[2]) != 0:
_choice_keys[int(r[2])] = id
_menu.set_item_accelerator(_menu.item_count - 1, int(r[2]) as Key)
Keymap.push_context("menu")
_menu.popup(Rect2i(Vector2i(at), Vector2i.ZERO))
## A NAME (user 2026-10-18: "when creating a new group i should be prompted for a name"):
## Blender's M → New Collection asks in a small popup. A field at the cursor, `default`
## selected; Enter runs on_ok(name) once the popup has closed, Esc / clicking away runs
## nothing. The `menu` context holds while it is open, so typing never flies or edits.
var _name_panel: PopupPanel = null
var _name_edit: LineEdit = null
var _name_title: Label = null
var _name_default := ""
var _name_ok: Callable = Callable()
func open_name(title: String, default: String, on_ok: Callable, at: Vector2) -> void:
if _name_panel == null:
_name_panel = PopupPanel.new()
_name_panel.name = "NamePrompt"
var box := VBoxContainer.new()
_name_title = Label.new()
box.add_child(_name_title)
_name_edit = LineEdit.new()
_name_edit.custom_minimum_size = Vector2(220, 0)
_name_edit.text_submitted.connect(func(t: String) -> void: submit_name(t))
box.add_child(_name_edit)
_name_panel.add_child(box)
add_child(_name_panel)
_name_panel.popup_hide.connect(func() -> void:
_name_ok = Callable()
Keymap.pop_context("menu"))
_name_title.text = title
_name_default = default
_name_edit.text = default
_name_ok = on_ok
Keymap.push_context("menu")
_name_panel.popup(Rect2i(Vector2i(at), Vector2i.ZERO))
_name_edit.grab_focus()
_name_edit.select_all()
func name_open() -> bool:
return _name_panel != null and _name_panel.visible
## Enter (and gates): close, then run on_ok with the trimmed name (an emptied field means
## the default it showed). Two frames later, like pick(), once the `menu` context is gone.
func submit_name(text: String) -> void:
var ok := _name_ok # popup_hide clears _name_ok: keep ours
var name := text.strip_edges()
if name == "":
name = _name_default
_name_panel.hide()
await get_tree().process_frame
await get_tree().process_frame
if ok.is_valid():
ok.call(name)
## Esc (and gates): close, run nothing.
func cancel_name() -> void:
if _name_panel != null:
_name_panel.hide()
## The rows' texts, in order (gates).
func choice_texts() -> Array:
var out: Array = []
for id: int in _calls:
out.append(_menu.get_item_text(_menu.get_item_index(id)))
return out
## Pick choice row `id` (by its text, for gates: choose_text).
func choose(id: int) -> void:
_menu.hide()
var c: Callable = _calls.get(id, Callable())
await get_tree().process_frame
await get_tree().process_frame
if c.is_valid():
c.call()
func choose_text(text: String) -> bool:
for id: int in _calls:
if _menu.get_item_text(_menu.get_item_index(id)) == text:
choose(id)
return true
return false
func item_count() -> int:
return _actions.size()
func has_action(action: String) -> bool:
return _actions.has(action)
func item_text_of(action: String) -> String:
var i := _actions.find(action)
return _menu.get_item_text(_menu.get_item_index(i)) if i >= 0 else ""
func accel_text_of(action: String) -> String:
return Keymap.describe(action) if _actions.has(action) else ""
## Close, then run — two frames later, once the `menu` context is gone, or the replayed
## key would be swallowed by it.
func pick(action: String) -> void:
_menu.hide()
last_run = action
await get_tree().process_frame
await get_tree().process_frame
Keymap.run(action)
## An action's own key picks it while the popup is open (the popup has focus, so the
## key would otherwise go nowhere).
func _on_window_input(ev: InputEvent) -> void:
if not (ev is InputEventKey) or not ev.is_pressed() or ev.is_echo():
return
var k := ev as InputEventKey
var code := int(k.physical_keycode if k.physical_keycode != 0 else k.keycode)
# A choice's own letter (open_choices' third element), unmodified.
if _choice_keys.has(code) and not (k.shift_pressed or k.ctrl_pressed or k.alt_pressed):
_menu.set_input_as_handled()
choose(int(_choice_keys[code]))
return
for a: String in _actions:
var b: Dictionary = Keymap.binding_of(a)
if b.has("mouse") or b.is_empty():
continue
if int(b.get("key", 0)) == code and bool(b.get("shift", false)) == k.shift_pressed \
and bool(b.get("ctrl", false)) == k.ctrl_pressed:
_menu.set_input_as_handled()
pick(a)
return
func _accel(action: String) -> int:
var b: Dictionary = Keymap.binding_of(action)
if b.is_empty() or b.has("mouse"):
return 0
var k := int(b.get("key", 0))
if bool(b.get("shift", false)):
k |= KEY_MASK_SHIFT
if bool(b.get("ctrl", false)):
k |= KEY_MASK_CTRL
return k