Skip to content

Commit 7acc01b

Browse files
authored
Merge pull request #276 from jchanvfx/context_menu_config_#254
Context menu config #254
2 parents f9de960 + 3d56b14 commit 7acc01b

23 files changed

+683
-207
lines changed

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,3 @@ build
2121
tests/*
2222

2323
*.egg-info
24-
*.json

NodeGraphQt/base/factory.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,8 @@ def create_node_instance(self, node_type=None):
5656
node_type = self.aliases[node_type]
5757

5858
_NodeClass = self.__nodes.get(node_type)
59-
if not _NodeClass:
60-
print('can\'t find node type {}'.format(node_type))
61-
return _NodeClass()
59+
if _NodeClass:
60+
return _NodeClass()
6261

6362
def register_node(self, node, alias=None):
6463
"""

NodeGraphQt/base/graph.py

Lines changed: 216 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
PortTypeEnum,
2525
ViewerEnum
2626
)
27+
from NodeGraphQt.errors import NodeCreationError, NodeDeletionError
2728
from NodeGraphQt.nodes.backdrop_node import BackdropNode
2829
from NodeGraphQt.nodes.base_node import BaseNode
2930
from 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

Comments
 (0)