# (c) 2023-present Eroax # (c) 2023-present Yagich # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends MarginContainer class_name AddNodeMenu ## A menu for adding nodes with a search bar. @onready var search_line_edit: LineEdit = %SearchLineEdit @onready var scroll_content_container: VBoxContainer = %ScrollContentContainer @onready var scroll_container: ScrollContainer = $VBoxContainer/ScrollContainer ## The categories currently shown in the menu. var categories: Dictionary = {} # Dictionary[String, Category] ## A list of categories to remember the collapsed state of so they remain collapsed when the search list is rebuilt. var collapsed_categories: Array[String] ## Emitted when a node is selected either by clicking on it or pressing [constant @GlobalScope.KEY_ENTER] while the [member search_line_edit] is focused. signal node_selected(type: String) func _ready() -> void: search("") ## Add a new category to the menu. func add_category(category_name: String) -> void: var c := Category.new(NodeDB.get_category_capitalization(category_name)) categories[category_name] = c scroll_content_container.add_child(c) c.collapse_toggled.connect(_on_category_collapse_toggled.bind(category_name)) c.item_pressed.connect( func(item: int): node_selected.emit(c.get_item_metadata(item, "type")) ) c.item_favorite_button_toggled.connect( func(item: int, toggled: bool): NodeDB.set_node_favorite(c.get_item_metadata(item, "type"), toggled) ) ## Add an item to a category. func add_category_item(category: String, item: String, tooltip: String = "", favorite: bool = false) -> void: var c: Category = categories[category] c.add_item(item, tooltip, favorite) ## Wrapper around [method add_category_item] and [method add_category]. Adds an item to a [param category], creating the category if it doesn't exist yet. func add_item(category: String, item: String, tooltip: String = "", favorite: bool = false) -> void: if !categories.has(category): add_category(category) add_category_item(category, item, tooltip, favorite) ## Get a [AddNodeMenu.Category] node by its' identifier. func get_category(category: String) -> Category: return categories[category] ## Focus the search bar and select all its' text. func focus_search_bar() -> void: search_line_edit.select_all() search_line_edit.grab_focus() ## Searches for a node using [SearchProvider] and puts the results as items. func search(text: String) -> void: scroll_content_container.get_children().map(func(c: Node): c.queue_free()) categories.clear() var search_results := SearchProvider.search(text) if search_results.is_empty(): return for nd in search_results: add_item(nd.category, nd.name, nd.description, NodeDB.is_node_favorite(nd.type)) var c := get_category(nd.category) c.set_item_metadata(c.get_item_count() - 1, "type", nd.type) c.set_item_metadata(c.get_item_count() - 1, "node_descriptor", weakref(nd)) c.set_collapsed(nd.category in collapsed_categories) get_category(categories.keys()[0]).highlight_item(0) ## Callback for [member search_line_edit]'s input events. Handles highlighting items when navigating with up/down arrow keys. func _on_search_line_edit_gui_input(event: InputEvent) -> void: if event.is_action_pressed("ui_down"): if scroll_content_container.get_child_count() == 0: return search_line_edit.accept_event() var category: Category for i: String in categories: var c: Category = categories[i] if c.get_highlighted_item() != -1: category = c break var item := category.get_highlighted_item() if item + 1 == category.get_item_count(): # reached the end of items in the current category category.unhighlight_all() var nc := get_next_visible_category(category.get_index()) nc.highlight_item(0) scroll_container.ensure_control_visible(nc.get_child(0)) return category.highlight_item(item + 1) scroll_container.ensure_control_visible(category.get_child(item + 1)) if event.is_action_pressed("ui_up"): if scroll_content_container.get_child_count() == 0: return search_line_edit.accept_event() var category: Category for i: String in categories: var c: Category = categories[i] if c.get_highlighted_item() != -1: category = c break var item := category.get_highlighted_item() if item - 1 == -1: # reached the beginning of items in the current category category.unhighlight_all() var nc := get_previous_visible_category(category.get_index()) nc.highlight_item(nc.get_item_count() - 1) scroll_container.ensure_control_visible(nc.get_child(nc.get_item_count() - 1)) return category.highlight_item(item - 1) scroll_container.ensure_control_visible(category.get_child(item - 1)) ## Returns the next uncollapsed category, starting from index [code]at[/code], wrapping around if no other ## categories are uncollapsed. func get_next_visible_category(at: int) -> Category: var i := at var s := 0 while s < scroll_content_container.get_child_count(): i = (i + 1) % scroll_content_container.get_child_count() if !(scroll_content_container.get_child(i) as Category).is_collapsed(): return scroll_content_container.get_child(i) s += 1 return scroll_content_container.get_child(at) ## Returns the previous uncollapsed category, starting from index [code]at[/code], wrapping around if no other ## categories are uncollapsed. func get_previous_visible_category(at: int) -> Category: var i := at var s := 0 while s < scroll_content_container.get_child_count(): i = (i - 1) % scroll_content_container.get_child_count() if !(scroll_content_container.get_child(i) as Category).is_collapsed(): return scroll_content_container.get_child(i) s += 1 return scroll_content_container.get_child(at) ## Callback for [member search_line_edit]. Handles emitting [signal node_selected] func _on_search_line_edit_text_submitted(_new_text: String) -> void: if scroll_content_container.get_child_count() == 0: return var category: Category for i: String in categories: var c: Category = categories[i] if c.get_highlighted_item() != -1: category = c break node_selected.emit(category.get_item_metadata(category.get_highlighted_item(), "type")) func _on_category_collapse_toggled(collapsed: bool, category: String) -> void: if collapsed: collapsed_categories.append(category) else: collapsed_categories.erase(category) ## A collapsible item menu. ## ## An analog to [Tree] and [ItemList] made with nodes. Allows collapsing its ## children [AddNodeMenu.CategoryItem] nodes and highlighting them.[br] ## [b]Note:[/b] only one [AddNodeMenu.CategoryItem] can be highlighted at any time. class Category extends VBoxContainer: const COLLAPSE_ICON := preload("res://graph_node_renderer/textures/collapse-icon.svg") const COLLAPSE_ICON_COLLAPSED := preload("res://graph_node_renderer/textures/collapse-icon-collapsed.svg") var collapse_button: Button ## Emitted when a child item has been pressed. signal item_pressed(item: int) ## Emitted when a child item's favorite button has been pressed. signal item_favorite_button_toggled(item: int, toggled: bool) ## Emitted when the category's collapsed state has been toggled. signal collapse_toggled(collapsed: bool) func _init(p_name: String) -> void: collapse_button = Button.new() collapse_button.alignment = HORIZONTAL_ALIGNMENT_LEFT collapse_button.icon = COLLAPSE_ICON collapse_button.toggle_mode = true collapse_button.flat = true collapse_button.size_flags_vertical = Control.SIZE_EXPAND_FILL collapse_button.text = p_name collapse_button.toggled.connect( func(toggled: bool): collapse_toggled.emit(toggled) ) add_child(collapse_button, false, Node.INTERNAL_MODE_FRONT) renamed.connect(func(): collapse_button.name = name ) collapse_button.toggled.connect(set_collapsed) ## If [param collapsed] is [code]true[/code], collapses the category, hiding its children. func set_collapsed(collapsed: bool) -> void: collapse_button.icon = COLLAPSE_ICON_COLLAPSED if collapsed else COLLAPSE_ICON collapse_button.set_pressed_no_signal(collapsed) for c: CategoryItem in get_children(): c.visible = !collapsed ## Add an item to the category. func add_item(p_name: String, p_tooltip: String, p_favorite: bool = false) -> void: var item := CategoryItem.new(p_name, p_tooltip, p_favorite) item.favorite_toggled.connect( func(toggled: bool): item_favorite_button_toggled.emit(item.get_index(), toggled) ) item.pressed.connect( func(): item_pressed.emit(item.get_index()) ) add_child(item) ## Set an item's metadata at index [param item]. func set_item_metadata(item: int, key: StringName, metadata: Variant) -> void: get_child(item).set_meta(key, metadata) ## Retrieve an item's metadata at index [param item]. func get_item_metadata(item: int, key: StringName) -> Variant: return get_child(item).get_meta(key) ## Get the amount of items in this category. func get_item_count() -> int: return get_child_count() ## Toggle an item at index [param item]'s favorite state. func set_item_favorite(item:int, favorite: bool) -> void: var _item := get_child(item) as CategoryItem _item.set_favorite(favorite) ## Returns [code]true[/code] if the item at [param item] is marked as favorite. func is_item_favorite(item: int) -> bool: var _item := get_child(item) as CategoryItem return _item.is_favorite() ## Highlight item at index [item], and unhighlight all other items. func highlight_item(item: int) -> void: for c: CategoryItem in get_children(): c.set_highlighted(c.get_index() == item) ## Unhighlight all items. func unhighlight_all() -> void: for c: CategoryItem in get_children(): c.set_highlighted(false) ## Returns the index of the currently highlighted item. Returns [code]-1[/code] if no item is highlighted in this category. func get_highlighted_item() -> int: for c: CategoryItem in get_children(): if c.is_highlighted: return c.get_index() return -1 func is_collapsed() -> bool: return collapse_button.button_pressed ## Represents an item in a [AddNodeMenu.Category]. ## ## A selectable and highlightable category item with a favorite button. class CategoryItem extends HBoxContainer: const FAVORITE_ICON := preload("res://graph_node_renderer/textures/favorite-icon.svg") const NON_FAVORITE_ICON := preload("res://graph_node_renderer/textures/non-favorite-icon.svg") const ITEM_MARGIN := 16 ## The stylebox to use if this item is highlighted. var highlighted_stylebox := StyleBoxFlat.new() var is_highlighted: bool var fav_button: Button var name_button: Button var panel: PanelContainer ## Emitted when this item has been pressed. signal pressed ## Emitted when this item's [member fav_button] has been pressed. signal favorite_toggled(toggled: bool) func _init(p_name: String, p_tooltip: String, p_favorite: bool) -> void: fav_button = Button.new() fav_button.icon = FAVORITE_ICON if p_favorite else NON_FAVORITE_ICON fav_button.toggle_mode = true fav_button.set_pressed_no_signal(p_favorite) fav_button.flat = true fav_button.toggled.connect( func(toggled: bool): favorite_toggled.emit(toggled) ) fav_button.toggled.connect(set_favorite) name_button = Button.new() name_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL name_button.text = p_name name_button.flat = true name_button.alignment = HORIZONTAL_ALIGNMENT_LEFT name_button.tooltip_text = p_tooltip name_button.pressed.connect( func(): pressed.emit() ) var mc := MarginContainer.new() mc.add_theme_constant_override(&"margin_left", ITEM_MARGIN) panel = PanelContainer.new() panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL panel.add_theme_stylebox_override(&"panel", highlighted_stylebox) highlighted_stylebox.bg_color = Color(0.0, 0.0, 0.0, 0.15) panel.self_modulate = Color.TRANSPARENT var inner_hb := HBoxContainer.new() inner_hb.add_child(fav_button) inner_hb.add_child(name_button) panel.add_child(inner_hb) add_child(mc) add_child(panel) ## Toggle this item's favorite state. func set_favorite(favorite: bool) -> void: fav_button.icon = FAVORITE_ICON if favorite else NON_FAVORITE_ICON fav_button.set_pressed_no_signal(favorite) ## Returns [code]true[/code] if this item is marked as favorite. func is_favorite() -> bool: return fav_button.icon == FAVORITE_ICON ## Toggle this item's highlighted state. func set_highlighted(highlighted: bool) -> void: is_highlighted = highlighted if highlighted: panel.self_modulate = Color.WHITE else: panel.self_modulate = Color.TRANSPARENT