2424 PortTypeEnum ,
2525 ViewerEnum
2626)
27+ from NodeGraphQt .errors import NodeCreationError , NodeDeletionError
2728from NodeGraphQt .nodes .backdrop_node import BackdropNode
2829from NodeGraphQt .nodes .base_node import BaseNode
2930from NodeGraphQt .nodes .group_node import GroupNode
@@ -158,20 +159,27 @@ def __init__(self, parent=None, **kwargs):
158159 kwargs .get ('viewer' ) or NodeViewer (undo_stack = self ._undo_stack ))
159160 self ._viewer .set_layout_direction (layout_direction )
160161
161- self ._build_context_menu ()
162+ self ._context_menu = {}
163+
164+ self ._register_context_menu ()
162165 self ._register_builtin_nodes ()
163166 self ._wire_signals ()
164167
165168 def __repr__ (self ):
166169 return '<{}("root") object at {}>' .format (
167170 self .__class__ .__name__ , hex (id (self )))
168171
169- def _build_context_menu (self ):
172+ def _register_context_menu (self ):
170173 """
171- build the essential menus commands for the graph context menu .
174+ Register the default context menus .
172175 """
173- from NodeGraphQt .base .graph_actions import build_context_menu
174- build_context_menu (self )
176+ if not self ._viewer :
177+ return
178+ menus = self ._viewer .context_menus ()
179+ if menus .get ('graph' ):
180+ self ._context_menu ['graph' ] = NodeGraphMenu (self , menus ['graph' ])
181+ if menus .get ('nodes' ):
182+ self ._context_menu ['nodes' ] = NodesMenu (self , menus ['nodes' ])
175183
176184 def _register_builtin_nodes (self ):
177185 """
@@ -696,12 +704,116 @@ def get_context_menu(self, menu):
696704 Returns:
697705 NodeGraphQt.NodeGraphMenu or NodeGraphQt.NodesMenu: context menu object.
698706 """
699- menus = self ._viewer .context_menus ()
700- if menus .get (menu ):
701- if menu == 'graph' :
702- return NodeGraphMenu (self , menus [menu ])
703- elif menu == 'nodes' :
704- return NodesMenu (self , menus [menu ])
707+ return self ._context_menu .get (menu )
708+
709+ def _deserialize_context_menu (self , menu , menu_data ):
710+ """
711+ Populate context menu from a dictionary.
712+
713+ Args:
714+ menu (NodeGraphQt.NodeGraphMenu or NodeGraphQt.NodesMenu):
715+ parent context menu.
716+ menu_data (list[dict] or dict): serialized menu data.
717+ """
718+ if not menu :
719+ raise ValueError ('No context menu named: "{}"' .format (menu ))
720+
721+ import sys
722+ import importlib .util
723+
724+ def build_menu_command (menu , data ):
725+ """
726+ Create menu command from serialized data.
727+
728+ Args:
729+ menu (NodeGraphQt.NodeGraphMenu or NodeGraphQt.NodesMenu):
730+ menu object.
731+ data (dict): serialized menu command data.
732+ """
733+ full_path = os .path .abspath (data ['file' ])
734+ base_dir , file_name = os .path .split (full_path )
735+ base_name = os .path .basename (base_dir )
736+ file_name , _ = file_name .split ('.' )
737+
738+ mod_name = '{}.{}' .format (base_name , file_name )
739+
740+ spec = importlib .util .spec_from_file_location (mod_name , full_path )
741+ mod = importlib .util .module_from_spec (spec )
742+ sys .modules [mod_name ] = mod
743+ spec .loader .exec_module (mod )
744+
745+ cmd_func = getattr (mod , data ['function_name' ])
746+ cmd_name = data .get ('label' ) or '<command>'
747+ cmd_shortcut = data .get ('shortcut' )
748+ menu .add_command (cmd_name , cmd_func , cmd_shortcut )
749+
750+ if isinstance (menu_data , dict ):
751+ item_type = menu_data .get ('type' )
752+ if item_type == 'separator' :
753+ menu .add_separator ()
754+ elif item_type == 'command' :
755+ build_menu_command (menu , menu_data )
756+ elif item_type == 'menu' :
757+ sub_menu = menu .add_menu (menu_data ['label' ])
758+ items = menu_data .get ('items' , [])
759+ self ._deserialize_context_menu (sub_menu , items )
760+ elif isinstance (menu_data , list ):
761+ for item_data in menu_data :
762+ self ._deserialize_context_menu (menu , item_data )
763+
764+ def set_context_menu (self , menu_name , data ):
765+ """
766+ Populate a context menu from serialized data.
767+
768+ serialized menu data example:
769+
770+ .. highlight:: python
771+ .. code-block:: python
772+
773+ [
774+ {
775+ 'type': 'menu',
776+ 'label': 'test sub menu',
777+ 'items': [
778+ {
779+ 'type': 'command',
780+ 'label': 'test command',
781+ 'file': '../path/to/my/test_module.py',
782+ 'function': 'run_test',
783+ 'shortcut': 'Ctrl+b'
784+ },
785+
786+ ]
787+ },
788+ ]
789+
790+ Args:
791+ menu_name (str): name of the parent context menu to populate under.
792+ data (dict): serialized menu data.
793+ """
794+ context_menu = self .get_context_menu (menu_name )
795+ self ._deserialize_context_menu (context_menu , data )
796+
797+ def set_context_menu_from_file (self , file_path , menu = None ):
798+ """
799+ Populate a context menu from a serialized json file.
800+
801+ Menu Types:
802+ - ``"graph"`` context menu from the node graph.
803+ - ``"nodes"`` context menu for the nodes.
804+
805+ Args:
806+ menu (str): name of the parent context menu to populate under.
807+ file_path (str): serialized menu commands json file.
808+ """
809+ menu = menu or 'graph'
810+ if not os .path .isfile (file_path ):
811+ return
812+
813+ with open (file_path ) as f :
814+ data = json .load (f )
815+ context_menu = self .get_context_menu (menu )
816+ self ._deserialize_context_menu (context_menu , data )
705817
706818 def disable_context_menu (self , disabled = True , name = 'all' ):
707819 """
@@ -997,16 +1109,22 @@ def format_color(clr):
9971109
9981110 node .update ()
9991111
1112+ undo_cmd = NodeAddedCmd (self , node , node .model .pos )
10001113 if push_undo :
1001- undo_cmd = NodeAddedCmd (self , node , node .model .pos )
1002- undo_cmd .setText ('create node: "{}"' .format (node .NODE_NAME ))
1114+ undo_label = 'create node: "{}"' .format (node .NODE_NAME )
1115+ self ._undo_stack .beginMacro (undo_label )
1116+ for n in self .selected_nodes ():
1117+ n .set_property ('selected' , False , push_undo = True )
10031118 self ._undo_stack .push (undo_cmd )
1119+ self ._undo_stack .endMacro ()
10041120 else :
1121+ for n in self .selected_nodes ():
1122+ n .set_property ('selected' , False , push_undo = False )
10051123 NodeAddedCmd (self , node , node .model .pos ).redo ()
10061124
10071125 self .node_created .emit (node )
10081126 return node
1009- raise TypeError ( ' \n \n >> Cannot find node:\t "{}"\n ' .format (node_type ))
1127+ raise NodeCreationError ( 'Can \' t find node: "{}"' .format (node_type ))
10101128
10111129 def add_node (self , node , pos = None , selected = True , push_undo = True ):
10121130 """
@@ -1081,6 +1199,10 @@ def delete_node(self, node, push_undo=True):
10811199 push_undo = push_undo )
10821200 p .clear_connections ()
10831201
1202+ # collapse group node before removing.
1203+ if isinstance (node , GroupNode ) and node .is_expanded :
1204+ node .collapse ()
1205+
10841206 if push_undo :
10851207 self ._undo_stack .push (NodeRemovedCmd (self , node ))
10861208 self ._undo_stack .endMacro ()
@@ -1106,6 +1228,10 @@ def remove_node(self, node, push_undo=True):
11061228 if push_undo :
11071229 self ._undo_stack .beginMacro ('delete node: "{}"' .format (node .name ()))
11081230
1231+ # collapse group node before removing.
1232+ if isinstance (node , GroupNode ) and node .is_expanded :
1233+ node .collapse ()
1234+
11091235 if isinstance (node , BaseNode ):
11101236 for p in node .input_ports ():
11111237 if p .locked ():
@@ -1143,6 +1269,11 @@ def delete_nodes(self, nodes, push_undo=True):
11431269 if push_undo :
11441270 self ._undo_stack .beginMacro ('deleted "{}" nodes' .format (len (nodes )))
11451271 for node in nodes :
1272+
1273+ # collapse group node before removing.
1274+ if isinstance (node , GroupNode ) and node .is_expanded :
1275+ node .collapse ()
1276+
11461277 if isinstance (node , BaseNode ):
11471278 for p in node .input_ports ():
11481279 if p .locked ():
@@ -1255,7 +1386,7 @@ def get_unique_name(self, name):
12551386 if name not in node_names :
12561387 return name
12571388
1258- regex = re .compile (r'[\w ]+(?: )* (\d+)' )
1389+ regex = re .compile (r'\w+ (\d+)$ ' )
12591390 search = regex .search (name )
12601391 if not search :
12611392 for x in range (1 , len (node_names ) + 2 ):
@@ -2023,6 +2154,9 @@ def __init__(self, parent=None, node=None, node_factory=None, **kwargs):
20232154 del self ._widget
20242155 del self ._sub_graphs
20252156
2157+ # clone context menu from the parent node graph.
2158+ self ._clone_context_menu_from_parent ()
2159+
20262160 def __repr__ (self ):
20272161 return '<{}("{}") object at {}>' .format (
20282162 self .__class__ .__name__ , self ._node .name (), hex (id (self )))
@@ -2033,6 +2167,48 @@ def _register_builtin_nodes(self):
20332167 """
20342168 return
20352169
2170+ def _clone_context_menu_from_parent (self ):
2171+ """
2172+ Clone the context menus from the parent node graph.
2173+ """
2174+ graph_menu = self .get_context_menu ('graph' )
2175+ parent_menu = self .parent_graph .get_context_menu ('graph' )
2176+ parent_viewer = self .parent_graph .viewer ()
2177+ excl_actions = [parent_viewer .qaction_for_undo (),
2178+ parent_viewer .qaction_for_redo ()]
2179+
2180+ def clone_menu (menu , menu_to_clone ):
2181+ """
2182+ Args:
2183+ menu (NodeGraphQt.NodeGraphMenu):
2184+ menu_to_clone (NodeGraphQt.NodeGraphMenu):
2185+ """
2186+ sub_items = []
2187+ for item in menu_to_clone .get_items ():
2188+ if item is None :
2189+ menu .add_separator ()
2190+ continue
2191+ name = item .name ()
2192+ if isinstance (item , NodeGraphMenu ):
2193+ sub_menu = menu .add_menu (name )
2194+ sub_items .append ([sub_menu , item ])
2195+ continue
2196+
2197+ if item in excl_actions :
2198+ continue
2199+
2200+ menu .add_command (
2201+ name ,
2202+ func = item .slot_function ,
2203+ shortcut = item .qaction .shortcut ()
2204+ )
2205+
2206+ for sub_menu , to_clone in sub_items :
2207+ clone_menu (sub_menu , to_clone )
2208+
2209+ # duplicate the menu items.
2210+ clone_menu (graph_menu , parent_menu )
2211+
20362212 def _build_port_nodes (self ):
20372213 """
20382214 Build the corresponding input & output nodes from the parent node ports
@@ -2310,12 +2486,34 @@ def delete_node(self, node, push_undo=True):
23102486 if node in port_nodes and node .parent_port is not None :
23112487 # note: port nodes can only be deleted by deleting the parent
23122488 # port object.
2313- raise RuntimeError (
2314- 'Can\' t delete node "{}" it is attached to port "{}"'
2315- .format (node , node .parent_port )
2489+ raise NodeDeletionError (
2490+ '{} can\' t be deleted as it is attached to a port!' .format (node )
23162491 )
23172492 super (SubGraph , self ).delete_node (node , push_undo = push_undo )
23182493
2494+ def delete_nodes (self , nodes , push_undo = True ):
2495+ """
2496+ Remove a list of specified nodes from the node graph.
2497+
2498+ Args:
2499+ nodes (list[NodeGraphQt.BaseNode]): list of node instances.
2500+ push_undo (bool): register the command to the undo stack. (default: True)
2501+ """
2502+ if not nodes :
2503+ return
2504+
2505+ port_nodes = self .get_input_port_nodes () + self .get_output_port_nodes ()
2506+ for node in nodes :
2507+ if node in port_nodes and node .parent_port is not None :
2508+ # note: port nodes can only be deleted by deleting the parent
2509+ # port object.
2510+ raise NodeDeletionError (
2511+ '{} can\' t be deleted as it is attached to a port!'
2512+ .format (node )
2513+ )
2514+
2515+ super (SubGraph , self ).delete_nodes (nodes , push_undo = push_undo )
2516+
23192517 def collapse_graph (self , clear_session = True ):
23202518 """
23212519 Collapse the current sub graph and hide its widget.
0 commit comments