class_name EditorContextMenu extends PopupPanel ## Shared Studio List popup used by right-click menus in both standalone editors. signal id_pressed(id: int) enum ItemRole { NORMAL, DESTRUCTIVE } const DEFAULT_EDITOR := &"prop" const DEFAULT_MIN_WIDTH := 208 const DEFAULT_ROW_HEIGHT := 48 const DEFAULT_GROUP_GAP := 8 const SHELL_INSET := 8 const ROW_RADIUS := 6 const TARGET_GAP := 12.0 var _visual_theme: EditorVisualTheme var _editor: StringName = DEFAULT_EDITOR var _font_size_override: int = -1 var _shell: MarginContainer var _scroll: ScrollContainer var _items: VBoxContainer var _row_buttons: Array[Button] = [] var _row_ids: Dictionary = {} var _keyboard_navigation_active: bool = false var _focused_row_index: int = -1 func _ready() -> void: _build_contents() if not about_to_popup.is_connected(_reset_keyboard_navigation): about_to_popup.connect(_reset_keyboard_navigation) if not popup_hide.is_connected(_reset_keyboard_navigation): popup_hide.connect(_reset_keyboard_navigation) if _visual_theme != null: _apply_visual_style() func configure(visual_theme: EditorVisualTheme, editor: StringName) -> void: _visual_theme = visual_theme _editor = editor if is_node_ready(): _apply_visual_style() func set_font_size(size: int) -> void: _font_size_override = maxi(1, size) if is_node_ready(): _apply_visual_style() func clear() -> void: _reset_keyboard_navigation() _row_buttons.clear() _row_ids.clear() if _items == null: return for child: Node in _items.get_children(): _items.remove_child(child) child.free() func add_item(label: String, id: int, role: ItemRole = ItemRole.NORMAL) -> void: if _items == null: _build_contents() var button := Button.new() button.text = label button.alignment = HORIZONTAL_ALIGNMENT_LEFT button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.custom_minimum_size = Vector2(_menu_min_width(), _menu_row_height()) button.focus_mode = Control.FOCUS_NONE button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND button.add_theme_font_size_override("font_size", _font_size()) button.add_theme_color_override("font_color", _text_color(role)) button.add_theme_color_override("font_hover_color", _text_color(role)) button.add_theme_color_override("font_pressed_color", _text_color(role)) button.add_theme_color_override("font_focus_color", _text_color(role)) button.add_theme_stylebox_override("normal", _make_row_box(Color(0.0, 0.0, 0.0, 0.0), Color(0, 0, 0, 0), 0)) button.add_theme_stylebox_override("hover", _make_hover_box()) button.add_theme_stylebox_override("pressed", _make_hover_box(true)) button.add_theme_stylebox_override("focus", _make_focus_box()) button.add_theme_stylebox_override("disabled", _make_row_box(Color(0.0, 0.0, 0.0, 0.0), Color(0, 0, 0, 0), 0)) button.set_meta("context_menu_id", id) button.set_meta("context_menu_role", role) button.pressed.connect(_on_row_pressed.bind(button)) button.mouse_entered.connect(_on_row_mouse_entered.bind(button)) _items.add_child(button) _row_buttons.append(button) _row_ids[id] = button func add_destructive_item(label: String, id: int) -> void: add_item(label, id, ItemRole.DESTRUCTIVE) func add_separator() -> void: if _items == null: _build_contents() var spacer := MarginContainer.new() spacer.custom_minimum_size.y = float(DEFAULT_GROUP_GAP + 1) spacer.add_theme_constant_override("margin_top", int(DEFAULT_GROUP_GAP / 2)) spacer.add_theme_constant_override("margin_bottom", int(DEFAULT_GROUP_GAP / 2)) var line := ColorRect.new() line.color = _theme_color(&"border", Color(1, 1, 1, 0.2)) line.custom_minimum_size.y = 1.0 line.size_flags_horizontal = Control.SIZE_EXPAND_FILL spacer.add_child(line) _items.add_child(spacer) func popup_at(global_position: Vector2, avoid_rect: Rect2 = Rect2()) -> void: if _items == null: _build_contents() var parent_viewport: Viewport = get_parent().get_viewport() if get_parent() != null else get_viewport() var safe_rect: Rect2 = parent_viewport.get_visible_rect() var margin := 8.0 var popup_size := _items.get_combined_minimum_size() + Vector2(SHELL_INSET * 2, SHELL_INSET * 2) var border_width := float(_theme_px(&"normal_border", 1)) popup_size += Vector2(border_width * 2.0, border_width * 2.0) popup_size.x = maxf(popup_size.x, float(_menu_min_width() + SHELL_INSET * 2)) var max_popup_size := Vector2(maxf(1.0, safe_rect.size.x - margin * 2.0), maxf(1.0, safe_rect.size.y - margin * 2.0)) popup_size = popup_size.min(max_popup_size) if _scroll != null: var content_height: float = _items.get_combined_minimum_size().y var available_content_height: float = max_popup_size.y - float(SHELL_INSET * 2) - border_width * 2.0 _scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO if content_height > available_content_height else ScrollContainer.SCROLL_MODE_DISABLED size = Vector2i(popup_size.ceil()) if _scroll != null: _scroll.scroll_vertical = 0 var max_x := maxf(safe_rect.position.x + margin, safe_rect.end.x - popup_size.x - margin) var max_y := maxf(safe_rect.position.y + margin, safe_rect.end.y - popup_size.y - margin) var popup_origin := _choose_popup_origin( global_position, avoid_rect, popup_size, safe_rect, margin, max_x, max_y ) var popup_position := Vector2i(popup_origin.round()) popup_on_parent(Rect2i(popup_position, Vector2i.ONE)) # PopupPanel sizes itself from the child layout on popup. Reapply the measured # target size after opening so the scroll region can honor the 48 px rows. size = Vector2i(popup_size.ceil()) var actual_size := Vector2(size) if popup_position.x + actual_size.x > safe_rect.end.x - margin: popup_origin.x = global_position.x - actual_size.x - margin if popup_position.y + actual_size.y > safe_rect.end.y - margin: popup_origin.y = global_position.y - actual_size.y - margin var actual_max_x := maxf(safe_rect.position.x + margin, safe_rect.end.x - actual_size.x - margin) var actual_max_y := maxf(safe_rect.position.y + margin, safe_rect.end.y - actual_size.y - margin) popup_origin.x = clampf(popup_origin.x, safe_rect.position.x + margin, actual_max_x) popup_origin.y = clampf(popup_origin.y, safe_rect.position.y + margin, actual_max_y) position = Vector2i(popup_origin.round()) func _choose_popup_origin( global_position: Vector2, avoid_rect: Rect2, popup_size: Vector2, safe_rect: Rect2, margin: float, max_x: float, max_y: float ) -> Vector2: var low_x: float = safe_rect.position.x + margin var low_y: float = safe_rect.position.y + margin var cursor_fallback := Vector2( clampf(global_position.x + margin, low_x, max_x), clampf(global_position.y + margin, low_y, max_y) ) if not avoid_rect.has_area(): return cursor_fallback var aligned_y := clampf(global_position.y - 20.0, low_y, max_y) var aligned_x := clampf(global_position.x + margin, low_x, max_x) var candidates: Array[Vector2] = [ Vector2(avoid_rect.end.x + TARGET_GAP, aligned_y), Vector2(avoid_rect.position.x - popup_size.x - TARGET_GAP, aligned_y), Vector2(aligned_x, avoid_rect.end.y + TARGET_GAP), Vector2(aligned_x, avoid_rect.position.y - popup_size.y - TARGET_GAP), cursor_fallback, ] for candidate: Vector2 in candidates: if candidate.x < low_x or candidate.y < low_y or candidate.x > max_x or candidate.y > max_y: continue var candidate_rect := Rect2(candidate, popup_size) if not candidate_rect.intersects(avoid_rect): return candidate return cursor_fallback func get_row_button(id: int) -> Button: return _row_ids.get(id) as Button func _build_contents() -> void: if _items != null: return _shell = MarginContainer.new() _shell.name = "MenuShell" _shell.add_theme_constant_override("margin_left", SHELL_INSET) _shell.add_theme_constant_override("margin_top", SHELL_INSET) _shell.add_theme_constant_override("margin_right", SHELL_INSET) _shell.add_theme_constant_override("margin_bottom", SHELL_INSET) _shell.size_flags_horizontal = Control.SIZE_EXPAND_FILL _shell.size_flags_vertical = Control.SIZE_EXPAND_FILL add_child(_shell) _scroll = ScrollContainer.new() _scroll.name = "MenuScroll" _scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED _scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL _scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL _shell.add_child(_scroll) _items = VBoxContainer.new() _items.name = "MenuItems" _items.add_theme_constant_override("separation", 0) _items.custom_minimum_size.x = float(_menu_min_width()) _scroll.add_child(_items) _apply_visual_style() func _apply_visual_style() -> void: if _visual_theme == null: return var surface := _theme_color(&"surface", Color("#252B38")) var border := _theme_color(&"border", Color("#FFFFFF33")) var radius := _theme_px(&"panel", 10) var panel := StyleBoxFlat.new() panel.bg_color = surface panel.border_color = border panel.set_border_width_all(_theme_px(&"normal_border", 1)) panel.set_corner_radius_all(radius) panel.shadow_color = Color(0.0, 0.0, 0.0, 0.28) panel.shadow_size = 8 panel.shadow_offset = Vector2(0.0, 3.0) add_theme_stylebox_override("panel", panel) if _shell != null: _shell.add_theme_constant_override("margin_left", SHELL_INSET) _shell.add_theme_constant_override("margin_top", SHELL_INSET) _shell.add_theme_constant_override("margin_right", SHELL_INSET) _shell.add_theme_constant_override("margin_bottom", SHELL_INSET) if _items != null: _items.custom_minimum_size.x = float(_menu_min_width()) for button: Button in _row_buttons: if not is_instance_valid(button): continue button.custom_minimum_size = Vector2(_menu_min_width(), _menu_row_height()) button.add_theme_font_size_override("font_size", _font_size()) var role := int(button.get_meta("context_menu_role", ItemRole.NORMAL)) var item_role: ItemRole = ItemRole.DESTRUCTIVE if role == ItemRole.DESTRUCTIVE else ItemRole.NORMAL button.add_theme_color_override("font_color", _text_color(item_role)) button.add_theme_color_override("font_hover_color", _text_color(item_role)) button.add_theme_color_override("font_pressed_color", _text_color(item_role)) button.add_theme_color_override("font_focus_color", _text_color(item_role)) button.add_theme_stylebox_override("normal", _make_row_box(Color(0.0, 0.0, 0.0, 0.0), Color(0, 0, 0, 0), 0)) button.add_theme_stylebox_override("hover", _make_hover_box()) button.add_theme_stylebox_override("pressed", _make_hover_box(true)) button.add_theme_stylebox_override("focus", _make_focus_box()) func _make_row_box(fill: Color, border: Color, border_width: int) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = fill style.border_color = border style.set_border_width_all(border_width) style.set_corner_radius_all(ROW_RADIUS) style.content_margin_left = 12 style.content_margin_right = 12 style.content_margin_top = 4 style.content_margin_bottom = 4 return style func _make_hover_box(pressed: bool = false) -> StyleBoxFlat: var hover_color := _theme_color(&"surface_hover", Color("#30394A")) if pressed: hover_color = hover_color.darkened(0.1) var style := _make_row_box(hover_color, _theme_color(&"edit", Color("#22C6FF")), 0) style.border_width_left = 3 style.content_margin_left = 9 return style func _make_focus_box() -> StyleBoxFlat: var style := _make_row_box(Color(0.0, 0.0, 0.0, 0.0), _theme_color(&"focus", Color("#8BD9FF")), 2) return style func _on_row_pressed(button: Button) -> void: if not is_instance_valid(button): return var id: int = int(button.get_meta("context_menu_id", -1)) id_pressed.emit(id) hide() func _input(event: InputEvent) -> void: if not visible or not event is InputEventKey: return var key_event := event as InputEventKey if not key_event.pressed: return if key_event.keycode == KEY_DOWN: _move_keyboard_focus(1) get_viewport().set_input_as_handled() elif key_event.keycode == KEY_UP: _move_keyboard_focus(-1) get_viewport().set_input_as_handled() func _move_keyboard_focus(direction: int) -> void: if _row_buttons.is_empty(): return var candidate_index: int if not _keyboard_navigation_active: candidate_index = 0 if direction > 0 else _row_buttons.size() - 1 else: candidate_index = _focused_row_index + direction for _attempt: int in range(_row_buttons.size()): candidate_index = posmod(candidate_index, _row_buttons.size()) var button: Button = _row_buttons[candidate_index] if is_instance_valid(button) and button.visible and not button.disabled: _enable_keyboard_navigation() _focused_row_index = candidate_index button.grab_focus() return candidate_index += direction func _enable_keyboard_navigation() -> void: _keyboard_navigation_active = true for button: Button in _row_buttons: if is_instance_valid(button): button.focus_mode = Control.FOCUS_ALL func _reset_keyboard_navigation() -> void: _keyboard_navigation_active = false _focused_row_index = -1 if not is_node_ready(): return var focus_owner: Control = get_viewport().gui_get_focus_owner() if focus_owner != null and _row_buttons.has(focus_owner): focus_owner.release_focus() for button: Button in _row_buttons: if is_instance_valid(button): button.focus_mode = Control.FOCUS_NONE func _on_row_mouse_entered(_button: Button) -> void: _reset_keyboard_navigation() func _theme_color(role: StringName, fallback: Color) -> Color: if _visual_theme == null: return fallback return _visual_theme.chrome_color(role, _editor) func _theme_px(role: StringName, fallback: int) -> int: if _visual_theme == null: return fallback return _visual_theme.chrome_px(role, _editor) func _font_size() -> int: if _font_size_override > 0: return _font_size_override return _theme_px(&"context_menu", 20) func _menu_row_height() -> int: return maxi(_theme_px(&"context_menu_row_height", DEFAULT_ROW_HEIGHT), DEFAULT_ROW_HEIGHT) func _menu_min_width() -> int: return maxi(_theme_px(&"context_menu_min_width", DEFAULT_MIN_WIDTH), DEFAULT_MIN_WIDTH) func _text_color(role: ItemRole) -> Color: if role == ItemRole.DESTRUCTIVE: return _theme_color(&"destructive", Color("#E66B75")) return _theme_color(&"text", Color("#F5F7FA"))