@tool class_name SelectableContainer extends MarginContainer signal activated signal deactivated @export var title: String = "Panel": set(value): title = value _refresh_header() @export var focus_key: Key = KEY_F1: set(value): focus_key = value _refresh_header() @export_node_path("Control") var initial_focus: NodePath var is_active: bool = false var _layout_editing := false var _registered := false var _blocked_shortcuts: Dictionary = {} var _frame_host: Control const EMBEDDED_PANEL = preload("res://themes/layout_embedded_panel.tres") var _last_focus: Control var _initial_focus_control: Control @onready var content: VBoxContainer = $MarginContainer/VBoxContainer/Content @onready var _panel: PanelContainer = $PanelContainer @onready var _shortcut: Button = $MarginContainer/VBoxContainer/HBoxContainer/Button func _ready() -> void: _refresh_header() if Engine.is_editor_hint(): return # Resolve authored paths before reparenting changes the runtime hierarchy. _initial_focus_control = get_node_or_null(initial_focus) as Control for child in get_children(): _route_content_child(child) child_entered_tree.connect(_on_child_entered_tree) add_to_group("keyboard_focus_containers") _registered = true focus_mode = Control.FOCUS_NONE _shortcut.focus_mode = Control.FOCUS_NONE _shortcut.pressed.connect(activate) refresh_shortcuts(get_tree()) get_window().focus_exited.connect(deactivate) theme_changed.connect(_update_style) get_viewport().gui_focus_changed.connect(_on_focus_changed) visibility_changed.connect(_on_visibility_changed) _update_style() content.focus_behavior_recursive = Control.FOCUS_BEHAVIOR_DISABLED func _refresh_header() -> void: var label := get_node_or_null("MarginContainer/VBoxContainer/HBoxContainer/Label") as Label var badge := get_node_or_null("MarginContainer/VBoxContainer/HBoxContainer/Button") as Button if label != null: label.text = title if badge != null: badge.visible = focus_key != KEY_NONE badge.disabled = not is_shortcut_available(focus_key) badge.text = OS.get_keycode_string(focus_key) + (" ยท ACTIVE" if is_active else "") badge.tooltip_text = "Shortcut already used by " + str(_blocked_shortcuts.get(focus_key, "")) if _blocked_shortcuts.has(focus_key) else "Focus this panel" func _on_child_entered_tree(child: Node) -> void: # SceneTree may still be adding the node when this signal fires. _route_content_child.call_deferred(child) func _route_content_child(child: Node) -> void: if not is_instance_valid(child) or child.get_parent() != self: return if child == _panel or child == $MarginContainer: return child.reparent(content, false) func activate() -> void: if Engine.is_editor_hint() or not focus_available(): return for other in get_tree().get_nodes_in_group("keyboard_focus_containers"): if other != self: other.deactivate() get_window().grab_focus() var was_active := is_active is_active = true focus_mode = Control.FOCUS_ALL content.focus_behavior_recursive = Control.FOCUS_BEHAVIOR_ENABLED _update_style() var target: Control = _initial_focus_control if is_instance_valid(_initial_focus_control) else null if is_instance_valid(_last_focus) and _last_focus.is_visible_in_tree(): target = _last_focus if target != null and target.is_visible_in_tree() and target.get_focus_mode_with_override() != Control.FOCUS_NONE: target.grab_focus() else: var controls := _focusable_controls(content) if controls.is_empty(): grab_focus() else: controls[0].grab_focus() if not was_active: activated.emit() func deactivate() -> void: if not is_active: return is_active = false focus_mode = Control.FOCUS_NONE var focus_owner := get_viewport().gui_get_focus_owner() if focus_owner == self or (focus_owner != null and is_ancestor_of(focus_owner)): focus_owner.release_focus() content.focus_behavior_recursive = Control.FOCUS_BEHAVIOR_DISABLED _update_style() deactivated.emit() func _input(event: InputEvent) -> void: if Engine.is_editor_hint() or not is_visible_in_tree() or _layout_editing: return if dispatch_focus_shortcut(event, get_tree()): get_viewport().set_input_as_handled() return if not event is InputEventKey: return var key := event as InputEventKey if not is_active or not key.pressed: return if key.keycode == KEY_ESCAPE: deactivate() get_viewport().set_input_as_handled() elif key.keycode == KEY_TAB and not key.ctrl_pressed and not key.alt_pressed and not key.meta_pressed: var controls := _focusable_controls(content) if not controls.is_empty(): var index := controls.find(get_viewport().gui_get_focus_owner()) var step := -1 if key.shift_pressed else 1 controls[posmod(index + step, controls.size())].grab_focus() get_viewport().set_input_as_handled() func _focusable_controls(node: Node) -> Array[Control]: var result: Array[Control] = [] for child in node.get_children(): if child is Control and child.is_visible_in_tree() and child.get_focus_mode_with_override() == Control.FOCUS_ALL: if not (child is BaseButton and child.disabled): result.append(child) result.append_array(_focusable_controls(child)) return result func _on_focus_changed(control: Control) -> void: if is_active and control != null and is_ancestor_of(control): _last_focus = control elif is_active and control != self: deactivate() func _on_visibility_changed() -> void: if not is_visible_in_tree(): deactivate() if is_inside_tree(): refresh_shortcuts(get_tree()) func _update_style() -> void: var style_name := "panel_active" if is_active else "panel_inactive" if is_instance_valid(_frame_host): _panel.add_theme_stylebox_override("panel", EMBEDDED_PANEL) _frame_host.add_theme_stylebox_override("panel", get_theme_stylebox(style_name, "SurfaceSide")) else: _panel.add_theme_stylebox_override("panel", get_theme_stylebox(style_name, "SurfaceSide")) _refresh_header() func get_focus_shortcuts() -> Array[Key]: return [focus_key] if focus_key != KEY_NONE else [] func is_shortcut_available(key: Key) -> bool: return key != KEY_NONE and not _blocked_shortcuts.has(key) func focus_available() -> bool: return _registered and is_inside_tree() and is_visible_in_tree() and not _layout_editing func activate_shortcut(_key: Key) -> void: activate() func set_layout_editing(editing: bool) -> void: _layout_editing = editing if editing: deactivate() if is_inside_tree(): refresh_shortcuts(get_tree()) func set_frame_host(host: Control) -> void: _frame_host = host if is_node_ready(): _update_style() func _exit_tree() -> void: _registered = false if not Engine.is_editor_hint() and get_tree() != null: refresh_shortcuts(get_tree()) static func refresh_shortcuts(tree: SceneTree) -> void: var scopes := tree.get_nodes_in_group("keyboard_focus_containers") scopes.sort_custom(func(a: Node, b: Node) -> bool: return a.get_instance_id() < b.get_instance_id()) var claimed: Dictionary = {} for scope in scopes: scope._blocked_shortcuts.clear() if scope.focus_available(): for key in scope.get_focus_shortcuts(): if key == KEY_NONE: continue if claimed.has(key) and claimed[key] != scope: scope._blocked_shortcuts[key] = claimed[key].title else: claimed[key] = scope scope._refresh_header() static func dispatch_focus_shortcut(event: InputEvent, tree: SceneTree) -> bool: if not event is InputEventKey or event.alt_pressed or event.ctrl_pressed or event.meta_pressed or event.shift_pressed: return false var matches := false for scope in tree.get_nodes_in_group("keyboard_focus_containers"): if scope.focus_available() and event.keycode in scope.get_focus_shortcuts(): matches = true break if not matches: return false refresh_shortcuts(tree) for scope in tree.get_nodes_in_group("keyboard_focus_containers"): if scope.focus_available() and event.keycode in scope.get_focus_shortcuts() and scope.is_shortcut_available(event.keycode): if event.pressed and not event.echo: scope.activate_shortcut(event.keycode) return true return false