OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / action_popup.gd

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