Browse Source

moved graphedit handling to seperate script

Jonathan Higgins 6 months ago
parent
commit
a74cf70fc7
4 changed files with 307 additions and 330 deletions
  1. 10 324
      scenes/main/control.gd
  2. 5 6
      scenes/main/control.tscn
  3. 291 0
      scenes/main/graph_edit.gd
  4. 1 0
      scenes/menu/search_menu.gd

+ 10 - 324
scenes/main/control.gd

@@ -3,13 +3,10 @@ extends Control
 var mainmenu_visible : bool = false #used to test if mainmenu is open
 var effect_position = Vector2(40,40) #tracks mouse position for node placement offset
 @onready var graph_edit = $GraphEdit
-var selected_nodes = {} #used to track which nodes in the GraphEdit are selected
 var cdpprogs_location #stores the cdp programs location from user prefs for easy access
 var delete_intermediate_outputs # tracks state of delete intermediate outputs toggle
 @onready var console_output: RichTextLabel = $Console/ConsoleOutput
 var final_output_dir
-var copied_nodes_data = [] #stores node data on ctrl+c
-var copied_connections = [] #stores all connections on ctrl+c
 var undo_redo := UndoRedo.new() 
 var output_audio_player #tracks the node that is the current output player for linking
 var input_audio_player #tracks node that is the current input player for linking
@@ -60,18 +57,16 @@ func _ready() -> void:
 	run_thread = preload("res://scenes/main/scripts/run_thread.gd").new()
 	run_thread.init(self, $ProgressWindow, $ProgressWindow/ProgressLabel, $ProgressWindow/ProgressBar, $GraphEdit, $Console, $Console/ConsoleOutput)
 	add_child(run_thread)
+	
+	graph_edit.init(self, $GraphEdit, Callable(open_help, "show_help_for_node"), $MultipleConnectionsPopup)
+	
 	save_load = preload("res://scenes/main/scripts/save_load.gd").new()
-	save_load.init(self, $GraphEdit, Callable(open_help, "show_help_for_node"), Callable(self, "_register_node_movement"), Callable(self, "_register_inputs_in_node"), Callable(self, "link_output"))
+	save_load.init(self, $GraphEdit, Callable(open_help, "show_help_for_node"), Callable(graph_edit, "_register_node_movement"), Callable(graph_edit, "_register_inputs_in_node"), Callable(self, "link_output"))
 	add_child(save_load)
+
 	
-	#Goes through all nodes in scene and checks for buttons in the make_node_buttons group
-	#Associates all buttons with the _on_button_pressed fuction and passes the button as an argument
-	for child in get_tree().get_nodes_in_group("make_node_buttons"):
-		if child is Button:
-			child.pressed.connect(_on_button_pressed.bind(child))
-	
-	get_node("SearchMenu").make_node.connect(_make_node_from_search_menu)
-	get_node("mainmenu").make_node.connect(_make_node_from_search_menu)
+	get_node("SearchMenu").make_node.connect(graph_edit._make_node)
+	get_node("mainmenu").make_node.connect(graph_edit._make_node)
 	get_node("mainmenu").open_help.connect(open_help.show_help_for_node)
 	get_node("Settings").open_cdp_location.connect(show_cdp_location)
 	get_node("Settings").console_on_top.connect(change_console_settings)
@@ -131,7 +126,7 @@ func new_patch():
 	get_node("GraphEdit").add_child(effect, true)
 	effect.connect("open_help", Callable(open_help, "show_help_for_node"))
 	effect.position_offset = Vector2((DisplayServer.screen_get_size().x - 480) / uiscale, 80)
-	_register_node_movement() #link nodes for tracking position changes for changes tracking
+	graph_edit._register_node_movement() #link nodes for tracking position changes for changes tracking
 	
 	changesmade = false #so it stops trying to save unchanged empty files
 	Global.infile = "no_file" #resets input to stop processes running with old files
@@ -224,13 +219,13 @@ func _process(delta: float) -> void:
 
 func _input(event):
 	if event.is_action_pressed("copy_node"):
-		copy_selected_nodes()
+		graph_edit.copy_selected_nodes()
 		get_viewport().set_input_as_handled()
 
 	elif event.is_action_pressed("paste_node"):
 		simulate_mouse_click() #hacky fix to stop tooltips getting stuck
 		await get_tree().process_frame
-		paste_copied_nodes()
+		graph_edit.paste_copied_nodes()
 		get_viewport().set_input_as_handled()
 	elif event.is_action_pressed("undo"):
 		undo_redo.undo()
@@ -265,316 +260,7 @@ func simulate_mouse_click():
 	up_event.position = click_pos
 	Input.parse_input_event(up_event)
 
-func _make_node_from_search_menu(command: String):
-	#close menu
-	$SearchMenu.hide()
-	
-	#Find node with matching name to button and create a version of it in the graph edit
-	#and position it close to the origin right click to open the menu
-	var effect: GraphNode = Nodes.get_node(NodePath(command)).duplicate()
-	effect.name = command
-	get_node("GraphEdit").add_child(effect, true)
-	effect.connect("open_help", Callable(open_help, "show_help_for_node"))
-	effect.set_position_offset((effect_position + graph_edit.scroll_offset) / graph_edit.zoom) #set node to current mouse position in graph edit
-	_register_inputs_in_node(effect) #link sliders for changes tracking
-	_register_node_movement() #link nodes for tracking position changes for changes tracking
-
-	changesmade = true
-
-	# Remove node with UndoRedo
-	undo_redo.create_action("Add Node")
-	undo_redo.add_undo_method(Callable(graph_edit, "remove_child").bind(effect))
-	undo_redo.add_undo_method(Callable(effect, "queue_free"))
-	undo_redo.add_undo_method(Callable(self, "_track_changes"))
-	undo_redo.commit_action()
-	
-
-func _on_button_pressed(button: Button):
-	#close menu
-	$mainmenu.hide()
-	mainmenu_visible = false
-	
-	#Find node with matching name to button and create a version of it in the graph edit
-	#and position it close to the origin right click to open the menu
-	var effect: GraphNode = Nodes.get_node(NodePath(button.name)).duplicate()
-	effect.name = button.name
-	get_node("GraphEdit").add_child(effect, true)
-	effect.connect("open_help", Callable(open_help, "show_help_for_node"))
-	effect.set_position_offset((effect_position + graph_edit.scroll_offset) / graph_edit.zoom) #set node to current mouse position in graph edit
-	_register_inputs_in_node(effect) #link sliders for changes tracking
-	_register_node_movement() #link nodes for tracking position changes for changes tracking
-
-	changesmade = true
-
-
-	# Remove node with UndoRedo
-	undo_redo.create_action("Add Node")
-	undo_redo.add_undo_method(Callable(graph_edit, "remove_child").bind(effect))
-	undo_redo.add_undo_method(Callable(effect, "queue_free"))
-	undo_redo.add_undo_method(Callable(self, "_track_changes"))
-	undo_redo.commit_action()
-
-func _on_graph_edit_connection_request(from_node: StringName, from_port: int, to_node: StringName, to_port: int) -> void:
-	#get_node("GraphEdit").connect_node(from_node, from_port, to_node, to_port)
-	var graph_edit = get_node("GraphEdit")
-	var to_graph_node = graph_edit.get_node(NodePath(to_node))
-
-	# Get the type of the input port using GraphNode's built-in method
-	var port_type = to_graph_node.get_input_port_type(to_port)
-
-	# If port type is 1 and already has a connection, reject the request
-	if port_type == 1:
-		var connections = graph_edit.get_connection_list()
-		var existing_connections = 0
-
-		for conn in connections:
-			if conn.to_node == to_node and conn.to_port == to_port:
-				existing_connections += 1
-				if existing_connections >= 1:
-					var interface_settings = ConfigHandler.load_interface_settings()
-					if interface_settings.disable_pvoc_warning == false:
-						$MultipleConnectionsPopup.popup_centered()
-					return
-
-	# If no conflict, allow the connection
-	graph_edit.connect_node(from_node, from_port, to_node, to_port)
-	changesmade = true
-
-func _on_graph_edit_disconnection_request(from_node: StringName, from_port: int, to_node: StringName, to_port: int) -> void:
-	get_node("GraphEdit").disconnect_node(from_node, from_port, to_node, to_port)
-	changesmade = true
-
-func _on_graph_edit_node_selected(node: Node) -> void:
-	selected_nodes[node] = true
-
-func _on_graph_edit_node_deselected(node: Node) -> void:
-	selected_nodes[node] = false
-
-func _unhandled_key_input(event: InputEvent) -> void:
-	if event is InputEventKey and event.pressed and not event.echo:
-		if event.keycode == KEY_BACKSPACE:
-			_on_graph_edit_delete_nodes_request(PackedStringArray(selected_nodes.keys().filter(func(k): return selected_nodes[k])))
-			pass
-
-func _on_graph_edit_delete_nodes_request(nodes: Array[StringName]) -> void:
-	var graph_edit = get_node("GraphEdit")
-	undo_redo.create_action("Delete Nodes (Undo only)")
-
-	for node in selected_nodes.keys():
-		if selected_nodes[node]:
-			if node.get_meta("command") == "inputfile" or node.get_meta("command") == "outputfile":
-				print("can't delete input or output")
-			else:
-				# Store duplicate and state for undo
-				var node_data = node.duplicate()
-				var position = node.position_offset
-
-				# Store all connections for undo
-				var conns = []
-				for con in graph_edit.get_connection_list():
-					if con["to_node"] == node.name or con["from_node"] == node.name:
-						conns.append(con)
-
-				# Delete
-				remove_connections_to_node(node)
-				node.queue_free()
-				changesmade = true
-
-				# Register undo restore
-				undo_redo.add_undo_method(Callable(graph_edit, "add_child").bind(node_data, true))
-				undo_redo.add_undo_method(Callable(node_data, "set_position_offset").bind(position))
-				for con in conns:
-					undo_redo.add_undo_method(Callable(graph_edit, "connect_node").bind(
-						con["from_node"], con["from_port"],
-						con["to_node"], con["to_port"]
-					))
-				undo_redo.add_undo_method(Callable(self, "set_node_selected").bind(node_data, true))
-				undo_redo.add_undo_method(Callable(self, "_track_changes"))
-				undo_redo.add_undo_method(Callable(self, "_register_inputs_in_node").bind(node_data)) #link sliders for changes tracking
-				undo_redo.add_undo_method(Callable(self, "_register_node_movement")) # link nodes for changes tracking
-
-	# Clear selection
-	selected_nodes = {}
-
-	undo_redo.commit_action()
-
-func set_node_selected(node: Node, selected: bool) -> void:
-	selected_nodes[node] = selected
-#
-func remove_connections_to_node(node):
-	for con in get_node("GraphEdit").get_connection_list():
-		if con["to_node"] == node.name or con["from_node"] == node.name:
-			get_node("GraphEdit").disconnect_node(con["from_node"], con["from_port"], con["to_node"], con["to_port"])
-			changesmade = true
-			
-#copy and paste nodes with vertical offset on paste
-func copy_selected_nodes():
-	copied_nodes_data.clear()
-	copied_connections.clear()
-
-	var graph_edit = get_node("GraphEdit")
-
-	# Store selected nodes and their slider values
-	for node in graph_edit.get_children():
-		# Check if the node is selected and not an 'inputfile' or 'outputfile'
-		if node is GraphNode and selected_nodes.get(node, false):
-			if node.get_meta("command") == "inputfile" or node.get_meta("command") == "outputfile":
-				continue  # Skip these nodes
-
-			var node_data = {
-				"name": node.name,
-				"type": node.get_class(),
-				"offset": node.position_offset,
-				"slider_values": {}
-			}
-
-			for child in node.get_children():
-				if child is HSlider or child is VSlider:
-					node_data["slider_values"][child.name] = child.value
-
-			copied_nodes_data.append(node_data)
-
-	# Store connections between selected nodes
-	for conn in graph_edit.get_connection_list():
-		var from_ref = graph_edit.get_node_or_null(NodePath(conn["from_node"]))
-		var to_ref = graph_edit.get_node_or_null(NodePath(conn["to_node"]))
-
-		var is_from_selected = from_ref != null and selected_nodes.get(from_ref, false)
-		var is_to_selected = to_ref != null and selected_nodes.get(to_ref, false)
-
-		# Skip if any of the connected nodes are 'inputfile' or 'outputfile'
-		if (from_ref != null and (from_ref.get_meta("command") == "inputfile" or from_ref.get_meta("command") == "outputfile")) or (to_ref != null and (to_ref.get_meta("command") == "inputfile" or to_ref.get_meta("command") == "outputfile")):
-			continue
-
-		if is_from_selected and is_to_selected:
-			# Store connection as dictionary
-			var conn_data = {
-				"from_node": conn["from_node"],
-				"from_port": conn["from_port"],
-				"to_node": conn["to_node"],
-				"to_port": conn["to_port"]
-			}
-			copied_connections.append(conn_data)
-
-func paste_copied_nodes():
-	if copied_nodes_data.is_empty():
-		return
-
-	var graph_edit = get_node("GraphEdit")
-	var name_map = {}
-	var pasted_nodes = []
-
-	# Step 1: Find topmost and bottommost Y of copied nodes
-	var min_y = INF
-	var max_y = -INF
-	for node_data in copied_nodes_data:
-		var y = node_data["offset"].y
-		min_y = min(min_y, y)
-		max_y = max(max_y, y)
-
-	# Step 2: Decide where to paste the group
-	var base_y_offset = max_y + 350  # Pasting below the lowest node
-
-	# Step 3: Paste nodes, preserving vertical layout
-	for node_data in copied_nodes_data:
-		var original_node = graph_edit.get_node_or_null(NodePath(node_data["name"]))
-		if not original_node:
-			continue
-
-		var new_node = original_node.duplicate()
-		new_node.name = node_data["name"] + "_copy_" + str(randi() % 10000)
-
-		var relative_y = node_data["offset"].y - min_y
-		new_node.position_offset = Vector2(
-			node_data["offset"].x,
-			base_y_offset + relative_y
-		)
-		
-
-		# Restore sliders
-		for child in new_node.get_children():
-			if child.name in node_data["slider_values"]:
-				child.value = node_data["slider_values"][child.name]
-
-		graph_edit.add_child(new_node, true)
-		new_node.connect("open_help", Callable(open_help, "show_help_for_node"))
-		_register_inputs_in_node(new_node) #link sliders for changes tracking
-		_register_node_movement() # link nodes for changes tracking
-		name_map[node_data["name"]] = new_node.name
-		pasted_nodes.append(new_node)
-
-
-	# Step 4: Reconnect new nodes
-	for conn_data in copied_connections:
-		var new_from = name_map.get(conn_data["from_node"], null)
-		var new_to = name_map.get(conn_data["to_node"], null)
-
-		if new_from and new_to:
-			graph_edit.connect_node(new_from, conn_data["from_port"], new_to, conn_data["to_port"])
-
-	# Step 5: Select pasted nodes
-	for pasted_node in pasted_nodes:
-		graph_edit.set_selected(pasted_node)
-		selected_nodes[pasted_node] = true
-	
-	changesmade = true
-	
-	# Remove node with UndoRedo
-	undo_redo.create_action("Paste Nodes")
-	for pasted_node in pasted_nodes:
-		undo_redo.add_undo_method(Callable(graph_edit, "remove_child").bind(pasted_node))
-		undo_redo.add_undo_method(Callable(pasted_node, "queue_free"))
-		undo_redo.add_undo_method(Callable(self, "remove_connections_to_node").bind(pasted_node))
-		undo_redo.add_undo_method(Callable(self, "_track_changes"))
-	undo_redo.commit_action()
-	
-
-#functions for tracking changes for save state detection
-func _register_inputs_in_node(node: Node):
-	#tracks input to nodes sliders and codeedit to track if patch is saved
-	# Track Sliders
-	for slider in node.find_children("*", "HSlider", true, false):
-		# Create a Callable to the correct method
-		var callable = Callable(self, "_on_any_slider_changed")
-		# Check if it's already connected, and connect if not
-		if not slider.is_connected("value_changed", callable):
-			slider.connect("value_changed", callable)
-	
-	for slider in node.find_children("*", "VBoxContainer", true, false):
-		# Also connect to meta_changed if the slider has that signal
-		if slider.has_signal("meta_changed"):
-			var meta_callable = Callable(self, "_on_any_slider_meta_changed")
-			if not slider.is_connected("meta_changed", meta_callable):
-				slider.connect("meta_changed", meta_callable)
-		
-	# Track CodeEdits
-	for editor in node.find_children("*", "CodeEdit", true, false):
-		var callable = Callable(self, "_on_any_input_changed")
-		if not editor.is_connected("text_changed", callable):
-			editor.connect("text_changed", callable)
-			
-func _on_any_slider_meta_changed():
-	changesmade = true
-	print("Meta changed in slider")
-	
-func _register_node_movement():
-	for graphnode in graph_edit.get_children():
-		if graphnode is GraphNode:
-			var callable = Callable(self, "_on_graphnode_moved")
-			if not graphnode.is_connected("position_offset_changed", callable):
-				graphnode.connect("position_offset_changed", callable)
-
-func _on_graphnode_moved():
-	changesmade = true
-	
-func _on_any_slider_changed(value: float) -> void:
-	changesmade = true
-	
-func _on_any_input_changed():
-	changesmade = true
 
-func _track_changes():
-	changesmade = true
 	
 
 

+ 5 - 6
scenes/main/control.tscn

@@ -435,12 +435,11 @@ offset_right = 592.0
 offset_bottom = 100.0
 text = "Stop Running Thread"
 
-[connection signal="connection_request" from="GraphEdit" to="." method="_on_graph_edit_connection_request"]
-[connection signal="delete_nodes_request" from="GraphEdit" to="." method="_on_graph_edit_delete_nodes_request"]
-[connection signal="disconnection_request" from="GraphEdit" to="." method="_on_graph_edit_disconnection_request"]
-[connection signal="focus_entered" from="GraphEdit" to="." method="_on_graph_edit_focus_entered"]
-[connection signal="node_deselected" from="GraphEdit" to="." method="_on_graph_edit_node_deselected"]
-[connection signal="node_selected" from="GraphEdit" to="." method="_on_graph_edit_node_selected"]
+[connection signal="connection_request" from="GraphEdit" to="GraphEdit" method="_on_connection_request"]
+[connection signal="delete_nodes_request" from="GraphEdit" to="GraphEdit" method="_on_graph_edit_delete_nodes_request"]
+[connection signal="disconnection_request" from="GraphEdit" to="GraphEdit" method="_on_graph_edit_disconnection_request"]
+[connection signal="node_deselected" from="GraphEdit" to="GraphEdit" method="_on_graph_edit_node_deselected"]
+[connection signal="node_selected" from="GraphEdit" to="GraphEdit" method="_on_graph_edit_node_selected"]
 [connection signal="popup_request" from="GraphEdit" to="." method="_on_graph_edit_popup_request"]
 [connection signal="dir_selected" from="FileDialog" to="." method="_on_file_dialog_dir_selected"]
 [connection signal="close_requested" from="mainmenu" to="." method="_on_mainmenu_close_requested"]

+ 291 - 0
scenes/main/graph_edit.gd

@@ -1,7 +1,298 @@
 extends GraphEdit
 
+var control_script
+var graph_edit
+var open_help
+var multiple_connections
+var selected_nodes = {} #used to track which nodes in the GraphEdit are selected
+var copied_nodes_data = [] #stores node data on ctrl+c
+var copied_connections = [] #stores all connections on ctrl+c
+
 # Called when the node enters the scene tree for the first time.
 func _ready() -> void:
 	snapping_enabled = false
 	show_grid = false
 	zoom = 0.9
+
+func init(main_node: Node, graphedit: GraphEdit, openhelp: Callable, multipleconnections: Window) -> void:
+	control_script = main_node
+	graph_edit = graphedit
+	open_help = openhelp
+	multiple_connections = multipleconnections
+
+func _make_node(command: String):
+	#Find node with matching name to button and create a version of it in the graph edit
+	#and position it close to the origin right click to open the menu
+	var effect: GraphNode = Nodes.get_node(NodePath(command)).duplicate()
+	effect.name = command
+	add_child(effect, true)
+	effect.connect("open_help", open_help)
+	effect.set_position_offset((control_script.effect_position + graph_edit.scroll_offset) / graph_edit.zoom) #set node to current mouse position in graph edit
+	_register_inputs_in_node(effect) #link sliders for changes tracking
+	_register_node_movement() #link nodes for tracking position changes for changes tracking
+
+	control_script.changesmade = true
+
+	# Remove node with UndoRedo
+	control_script.undo_redo.create_action("Add Node")
+	control_script.undo_redo.add_undo_method(Callable(graph_edit, "remove_child").bind(effect))
+	control_script.undo_redo.add_undo_method(Callable(effect, "queue_free"))
+	control_script.undo_redo.add_undo_method(Callable(self, "_track_changes"))
+	control_script.undo_redo.commit_action()
+	
+
+func _on_connection_request(from_node: StringName, from_port: int, to_node: StringName, to_port: int) -> void:
+	var to_graph_node = get_node(NodePath(to_node))
+
+	# Get the type of the input port using GraphNode's built-in method
+	var port_type = to_graph_node.get_input_port_type(to_port)
+
+	# If port type is 1 and already has a connection, reject the request
+	if port_type == 1:
+		var connections = get_connection_list()
+		var existing_connections = 0
+
+		for conn in connections:
+			if conn.to_node == to_node and conn.to_port == to_port:
+				existing_connections += 1
+				if existing_connections >= 1:
+					var interface_settings = ConfigHandler.load_interface_settings()
+					if interface_settings.disable_pvoc_warning == false:
+						multiple_connections.popup_centered()
+					return
+
+	# If no conflict, allow the connection
+	connect_node(from_node, from_port, to_node, to_port)
+	control_script.changesmade = true
+
+func _on_graph_edit_disconnection_request(from_node: StringName, from_port: int, to_node: StringName, to_port: int) -> void:
+	disconnect_node(from_node, from_port, to_node, to_port)
+	control_script.changesmade = true
+
+func _on_graph_edit_node_selected(node: Node) -> void:
+	selected_nodes[node] = true
+
+func _on_graph_edit_node_deselected(node: Node) -> void:
+	selected_nodes[node] = false
+
+func _unhandled_key_input(event: InputEvent) -> void:
+	if event is InputEventKey and event.pressed and not event.echo:
+		if event.keycode == KEY_BACKSPACE:
+			_on_graph_edit_delete_nodes_request(PackedStringArray(selected_nodes.keys().filter(func(k): return selected_nodes[k])))
+			pass
+
+func _on_graph_edit_delete_nodes_request(nodes: Array[StringName]) -> void:
+	control_script.undo_redo.create_action("Delete Nodes (Undo only)")
+
+	for node in selected_nodes.keys():
+		if selected_nodes[node]:
+			if node.get_meta("command") == "inputfile" or node.get_meta("command") == "outputfile":
+				print("can't delete input or output")
+			else:
+				# Store duplicate and state for undo
+				var node_data = node.duplicate()
+				var position = node.position_offset
+
+				# Store all connections for undo
+				var conns = []
+				for con in get_connection_list():
+					if con["to_node"] == node.name or con["from_node"] == node.name:
+						conns.append(con)
+
+				# Delete
+				remove_connections_to_node(node)
+				node.queue_free()
+				control_script.changesmade = true
+
+				# Register undo restore
+				control_script.undo_redo.add_undo_method(Callable(self, "add_child").bind(node_data, true))
+				control_script.undo_redo.add_undo_method(Callable(node_data, "set_position_offset").bind(position))
+				for con in conns:
+					control_script.undo_redo.add_undo_method(Callable(self, "connect_node").bind(
+						con["from_node"], con["from_port"],
+						con["to_node"], con["to_port"]
+					))
+				control_script.undo_redo.add_undo_method(Callable(self, "set_node_selected").bind(node_data, true))
+				control_script.undo_redo.add_undo_method(Callable(self, "_track_changes"))
+				control_script.undo_redo.add_undo_method(Callable(self, "_register_inputs_in_node").bind(node_data)) #link sliders for changes tracking
+				control_script.undo_redo.add_undo_method(Callable(self, "_register_node_movement")) # link nodes for changes tracking
+
+	# Clear selection
+	selected_nodes = {}
+
+	control_script.undo_redo.commit_action()
+
+func set_node_selected(node: Node, selected: bool) -> void:
+	selected_nodes[node] = selected
+#
+func remove_connections_to_node(node):
+	for con in get_connection_list():
+		if con["to_node"] == node.name or con["from_node"] == node.name:
+			disconnect_node(con["from_node"], con["from_port"], con["to_node"], con["to_port"])
+			control_script.changesmade = true
+			
+#copy and paste nodes with vertical offset on paste
+func copy_selected_nodes():
+	copied_nodes_data.clear()
+	copied_connections.clear()
+
+	# Store selected nodes and their slider values
+	for node in get_children():
+		# Check if the node is selected and not an 'inputfile' or 'outputfile'
+		if node is GraphNode and selected_nodes.get(node, false):
+			if node.get_meta("command") == "inputfile" or node.get_meta("command") == "outputfile":
+				continue  # Skip these nodes
+
+			var node_data = {
+				"name": node.name,
+				"type": node.get_class(),
+				"offset": node.position_offset,
+				"slider_values": {}
+			}
+
+			for child in node.get_children():
+				if child is HSlider or child is VSlider:
+					node_data["slider_values"][child.name] = child.value
+
+			copied_nodes_data.append(node_data)
+
+	# Store connections between selected nodes
+	for conn in get_connection_list():
+		var from_ref = get_node_or_null(NodePath(conn["from_node"]))
+		var to_ref = get_node_or_null(NodePath(conn["to_node"]))
+
+		var is_from_selected = from_ref != null and selected_nodes.get(from_ref, false)
+		var is_to_selected = to_ref != null and selected_nodes.get(to_ref, false)
+
+		# Skip if any of the connected nodes are 'inputfile' or 'outputfile'
+		if (from_ref != null and (from_ref.get_meta("command") == "inputfile" or from_ref.get_meta("command") == "outputfile")) or (to_ref != null and (to_ref.get_meta("command") == "inputfile" or to_ref.get_meta("command") == "outputfile")):
+			continue
+
+		if is_from_selected and is_to_selected:
+			# Store connection as dictionary
+			var conn_data = {
+				"from_node": conn["from_node"],
+				"from_port": conn["from_port"],
+				"to_node": conn["to_node"],
+				"to_port": conn["to_port"]
+			}
+			copied_connections.append(conn_data)
+
+func paste_copied_nodes():
+	if copied_nodes_data.is_empty():
+		return
+
+	var name_map = {}
+	var pasted_nodes = []
+
+	# Step 1: Find topmost and bottommost Y of copied nodes
+	var min_y = INF
+	var max_y = -INF
+	for node_data in copied_nodes_data:
+		var y = node_data["offset"].y
+		min_y = min(min_y, y)
+		max_y = max(max_y, y)
+
+	# Step 2: Decide where to paste the group
+	var base_y_offset = max_y + 350  # Pasting below the lowest node
+
+	# Step 3: Paste nodes, preserving vertical layout
+	for node_data in copied_nodes_data:
+		var original_node = get_node_or_null(NodePath(node_data["name"]))
+		if not original_node:
+			continue
+
+		var new_node = original_node.duplicate()
+		new_node.name = node_data["name"] + "_copy_" + str(randi() % 10000)
+
+		var relative_y = node_data["offset"].y - min_y
+		new_node.position_offset = Vector2(
+			node_data["offset"].x,
+			base_y_offset + relative_y
+		)
+		
+
+		# Restore sliders
+		for child in new_node.get_children():
+			if child.name in node_data["slider_values"]:
+				child.value = node_data["slider_values"][child.name]
+
+		add_child(new_node, true)
+		new_node.connect("open_help", open_help)
+		_register_inputs_in_node(new_node) #link sliders for changes tracking
+		_register_node_movement() # link nodes for changes tracking
+		name_map[node_data["name"]] = new_node.name
+		pasted_nodes.append(new_node)
+
+
+	# Step 4: Reconnect new nodes
+	for conn_data in copied_connections:
+		var new_from = name_map.get(conn_data["from_node"], null)
+		var new_to = name_map.get(conn_data["to_node"], null)
+
+		if new_from and new_to:
+			connect_node(new_from, conn_data["from_port"], new_to, conn_data["to_port"])
+
+	# Step 5: Select pasted nodes
+	for pasted_node in pasted_nodes:
+		set_selected(pasted_node)
+		selected_nodes[pasted_node] = true
+	
+	control_script.changesmade = true
+	
+	# Remove node with UndoRedo
+	control_script.undo_redo.create_action("Paste Nodes")
+	for pasted_node in pasted_nodes:
+		control_script.undo_redo.add_undo_method(Callable(self, "remove_child").bind(pasted_node))
+		control_script.undo_redo.add_undo_method(Callable(pasted_node, "queue_free"))
+		control_script.undo_redo.add_undo_method(Callable(self, "remove_connections_to_node").bind(pasted_node))
+		control_script.undo_redo.add_undo_method(Callable(self, "_track_changes"))
+	control_script.undo_redo.commit_action()
+	
+
+#functions for tracking changes for save state detection
+func _register_inputs_in_node(node: Node):
+	#tracks input to nodes sliders and codeedit to track if patch is saved
+	# Track Sliders
+	for slider in node.find_children("*", "HSlider", true, false):
+		# Create a Callable to the correct method
+		var callable = Callable(self, "_on_any_slider_changed")
+		# Check if it's already connected, and connect if not
+		if not slider.is_connected("value_changed", callable):
+			slider.connect("value_changed", callable)
+	
+	for slider in node.find_children("*", "VBoxContainer", true, false):
+		# Also connect to meta_changed if the slider has that signal
+		if slider.has_signal("meta_changed"):
+			var meta_callable = Callable(self, "_on_any_slider_meta_changed")
+			if not slider.is_connected("meta_changed", meta_callable):
+				slider.connect("meta_changed", meta_callable)
+		
+	# Track CodeEdits
+	for editor in node.find_children("*", "CodeEdit", true, false):
+		var callable = Callable(self, "_on_any_input_changed")
+		if not editor.is_connected("text_changed", callable):
+			editor.connect("text_changed", callable)
+			
+func _on_any_slider_meta_changed():
+	control_script.changesmade = true
+	print("Meta changed in slider")
+	
+func _register_node_movement():
+	for graphnode in get_children():
+		if graphnode is GraphNode:
+			var callable = Callable(self, "_on_graphnode_moved")
+			if not graphnode.is_connected("position_offset_changed", callable):
+				graphnode.connect("position_offset_changed", callable)
+
+func _on_graphnode_moved():
+	control_script.changesmade = true
+	
+func _on_any_slider_changed(value: float) -> void:
+	control_script.changesmade = true
+	
+func _on_any_input_changed():
+	control_script.changesmade = true
+
+func _track_changes():
+	control_script.changesmade = true

+ 1 - 0
scenes/menu/search_menu.gd

@@ -74,4 +74,5 @@ func _on_search_bar_text_changed(new_text: String) -> void:
 	display_items(new_text)
 	
 func _on_item_selected(key: String):
+	self.hide()
 	make_node.emit(key) # send out signal to main patch