diff --git a/.travis.yml b/.travis.yml index 1850ae2..b82ed3b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,8 +23,6 @@ before_install: install: - cd .. - - git clone https://github.com/nxt-dev/nxt.git - - pip install ./nxt - pip install ./nxt_editor - pip install importlib-metadata==3.4 - pip install twine diff --git a/nxt_editor/actions.py b/nxt_editor/actions.py index f496101..5b9c8e9 100644 --- a/nxt_editor/actions.py +++ b/nxt_editor/actions.py @@ -388,6 +388,22 @@ def clear_logs(): self.clear_logs_action.setShortcutContext(context) self.available_without_model.append(self.clear_logs_action) + # Toggle error ding sound + def toggle_ding(): + pref_key = user_dir.USER_PREF.DING + ding_state = self.toggle_ding_action.isChecked() + user_dir.user_prefs[pref_key] = ding_state + + self.toggle_ding_action = NxtAction('Error sound', parent=self) + self.toggle_ding_action.setWhatsThis('When enabled a "ding" sound will be played when NXT is given bad input ' + 'or encounters and error') + self.toggle_ding_action.setCheckable(True) + _ding_state = user_dir.user_prefs.get(user_dir.USER_PREF.DING, True) + self.toggle_ding_action.setChecked(_ding_state) + self.toggle_ding_action.triggered.connect(toggle_ding) + self.toggle_ding_action.setShortcutContext(context) + self.available_without_model.append(self.toggle_ding_action) + self.action_display_order = [self.find_node_action, self.new_graph_action, self.open_file_action, self.undo_action, @@ -400,6 +416,7 @@ def clear_logs(): self.output_log_action, self.hotkey_editor_action, self.workflow_tools_action, + self.toggle_ding_action, self.clear_logs_action, self.close_action] diff --git a/nxt_editor/commands.py b/nxt_editor/commands.py index 168dc72..7339801 100644 --- a/nxt_editor/commands.py +++ b/nxt_editor/commands.py @@ -1649,6 +1649,53 @@ def redo(self): self.setText("Set {} color to {}".format(layer.filepath, self.color)) +class SetLayerLock(NxtCommand): + def __init__(self, lock, layer_path, model): + """Sets the color for a given layer, if the layer is not a top layer + the top layer store an overrides. + :param lock: bool of the desired lock state, if None is passed its + considered a revert. + :param layer_path: real path of layer + :param model: StageModel + """ + super(SetLayerLock, self).__init__(model) + self.layer_path = layer_path + self.lock = lock + self.old_lock = None + self.model = model + self.stage = model.stage + self.prev_target_layer_path = None + + @processing + def undo(self): + layer = self.model.lookup_layer(self.layer_path) + if layer is self.model.top_layer: + layer.lock = self.old_lock + else: + layer.set_locked_over(self.old_lock) + self.undo_effected_layer(self.model.top_layer.real_path) + self.model.layer_lock_changed.emit(self.layer_path) + if self.prev_target_layer_path: + prev_tgt = self.model.lookup_layer(self.prev_target_layer_path) + if prev_tgt != self.model.target_layer: + self.model._set_target_layer(prev_tgt) + + @processing + def redo(self): + layer = self.model.lookup_layer(self.layer_path) + self.prev_target_layer_path = self.model.get_layer_path(self.model.target_layer, LAYERS.TARGET) + self.old_lock = layer.get_locked(fallback_to_local=False) + layer.set_locked_over(self.lock) + self.redo_effected_layer(self.model.top_layer.real_path) + self.model.layer_lock_changed.emit(self.layer_path) + move_tgt = layer == self.model.target_layer + if move_tgt: + self.model._set_target_layer(self.model.top_layer) + logger.warning('You locked your target layer, setting target layer to TOP layer.') + self.model.request_ding.emit() + self.setText("Set {} lock to {}".format(layer.filepath, self.lock)) + + def _add_node_hierarchy(base_node_path, model, layer): stage = model.stage comp_layer = model.comp_layer diff --git a/nxt_editor/dockwidgets/code_editor.py b/nxt_editor/dockwidgets/code_editor.py index b0c61d1..792184c 100644 --- a/nxt_editor/dockwidgets/code_editor.py +++ b/nxt_editor/dockwidgets/code_editor.py @@ -24,7 +24,8 @@ class CodeEditor(DockWidgetBase): def __init__(self, title='Code Editor', parent=None, minimum_width=500): - super(CodeEditor, self).__init__(title=title, parent=parent, minimum_width=minimum_width) + super(CodeEditor, self).__init__(title=title, parent=parent, + minimum_width=minimum_width) self.setObjectName('Code Editor') self.main_window = parent self.ce_actions = self.main_window.code_editor_actions @@ -32,6 +33,7 @@ def __init__(self, title='Code Editor', parent=None, minimum_width=500): # local attributes self.editing_active = False self.code_is_local = False + self.locked = False self.node_path = None self.node_name = '' self.actual_display_state = '' @@ -187,15 +189,15 @@ def __init__(self, title='Code Editor', parent=None, minimum_width=500): QtCore.Qt.AlignRight) # remove code button - self.remove_code_button = PixmapButton(pixmap=':icons/icons/delete.png', - pixmap_hover=':icons/icons/delete_hover.png', - pixmap_pressed=':icons/icons/delete_pressed.png', - size=12, - parent=self.code_frame) - self.remove_code_button.setToolTip('Remove Compute') - self.remove_code_button.setStyleSheet('QToolTip {color: white; border: 1px solid #3E3E3E}') - self.remove_code_button.clicked.connect(self.revert_code) - self.buttons_layout.addWidget(self.remove_code_button, 0, 7, + self.revert_code_button = PixmapButton(pixmap=':icons/icons/delete.png', + pixmap_hover=':icons/icons/delete_hover.png', + pixmap_pressed=':icons/icons/delete_pressed.png', + size=12, + parent=self.code_frame) + self.revert_code_button.setToolTip('Remove Compute') + self.revert_code_button.setStyleSheet('QToolTip {color: white; border: 1px solid #3E3E3E}') + self.revert_code_button.clicked.connect(self.revert_code) + self.buttons_layout.addWidget(self.revert_code_button, 0, 7, QtCore.Qt.AlignRight) if not self.main_window.in_startup: # default state @@ -230,6 +232,7 @@ def set_stage_model_connections(self, model, connect): self.model_signal_connections = [ (model.node_focus_changed, self.accept_edit), (model.node_focus_changed, self.set_represented_node), + (model.layer_lock_changed, self.handle_lock_changed), (model.nodes_changed, self.update_editor), (model.attrs_changed, self.update_editor), (model.data_state_changed, self.update_editor), @@ -247,6 +250,20 @@ def on_stage_model_destroyed(self): self.editor.hide() self.update_background() + def handle_lock_changed(self, *args): + # TODO: Make it a user pref to lock the code editor when node is locked? + # self.locked = self.stage_model.get_node_locked(self.node_path) + self.name_label.setReadOnly(self.locked) + # Enable/Disable + self.accept_button.setEnabled(not self.locked) + self.cancel_button.setEnabled(not self.locked) + self.revert_code_button.setEnabled(not self.locked) + keep_active = [self.ce_actions.copy_resolved_action] + for action in self.ce_actions.actions() + self.exec_actions.actions(): + if action in keep_active: + continue + action.setEnabled(not self.locked) + def set_represented_node(self): self.node_path = self.stage_model.node_focus if not self.node_path: @@ -265,6 +282,7 @@ def set_represented_node(self): self.display_editor() self.display_details() + self.handle_lock_changed() def copy_resolved(self): if not self.stage_model: @@ -429,7 +447,7 @@ def edit_format_characters(self, value=None): def enter_editing(self): # prevent re-activating when the mouse is clicked inside the editor - if self.editing_active: + if self.editing_active or self.locked: return self.cached_code = self.editor.toPlainText() self.cached_code_lines = self.cached_code.split('\n') diff --git a/nxt_editor/dockwidgets/layer_manager.py b/nxt_editor/dockwidgets/layer_manager.py index 168a26f..c65c9a1 100644 --- a/nxt_editor/dockwidgets/layer_manager.py +++ b/nxt_editor/dockwidgets/layer_manager.py @@ -71,8 +71,9 @@ def __init__(self, layer_actions): self.target_delegate = PixMapCheckboxDelegate(pencil_pix) layer_pix = QtGui.QPixmap(':icons/icons/layers_hover.png') self.display_delegate = PixMapCheckboxDelegate(layer_pix) - self.solo_delegate = LetterCheckboxDelegeate('S') self.mute_delgate = LetterCheckboxDelegeate('M') + self.solo_delegate = LetterCheckboxDelegeate('S') + self.lock_delegate = LetterCheckboxDelegeate('L') self.setItemDelegateForColumn(LayerModel.ALIAS_COLUMN, self.alias_delegate) self.setItemDelegateForColumn(LayerModel.TARGET_COLUMN, @@ -83,6 +84,8 @@ def __init__(self, layer_actions): self.mute_delgate) self.setItemDelegateForColumn(LayerModel.SOLO_COLUMN, self.solo_delegate) + self.setItemDelegateForColumn(LayerModel.LOCK_COLUMN, + self.lock_delegate) self.setHeaderHidden(True) self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.custom_context_menu) @@ -121,6 +124,10 @@ def on_item_clicked(self, clicked_idx): return layer = clicked_idx.internalPointer() layer_path = self.model().stage_model.get_layer_path(layer) + if self.model().stage_model.get_layer_locked(layer_path): + logger.warning('The layer "{}" is locked!'.format(layer.alias)) + self.model().stage_model.request_ding.emit() + return self.model().stage_model.set_target_layer(layer_path) def on_item_dbl_clicked(self, clicked_idx): @@ -128,6 +135,10 @@ def on_item_dbl_clicked(self, clicked_idx): return if not self.model().stage_model: return + layer = clicked_idx.internalPointer() + layer_path = self.model().stage_model.get_layer_path(layer) + if self.model().stage_model.get_layer_locked(layer_path): + return self.model().stage_model.set_selection([nxt_path.WORLD]) def custom_context_menu(self, pos): @@ -208,8 +219,9 @@ class LayerModel(QtCore.QAbstractItemModel): TARGET_COLUMN = 2 MUTE_COLUMN = 3 SOLO_COLUMN = 4 - HAS_SELECTED_COLUMN = 5 - UNSAVED = 6 + LOCK_COLUMN = 5 + HAS_SELECTED_COLUMN = 6 + UNSAVED = 7 def __init__(self, stage_model): super(LayerModel, self).__init__() @@ -406,7 +418,7 @@ def columnCount(self, parent=None): """Returns number of columns in the model. Part of QAbstractItemModel """ - return 5 + return 6 def data(self, index, role=None): """Returns the data continaed at given model index with given role. @@ -423,6 +435,7 @@ def data(self, index, role=None): return is_disp = layer == self.stage_model.display_layer is_target = layer == self.stage_model.target_layer + is_locked = layer.get_locked() if role == QtCore.Qt.EditRole: if column == self.ALIAS_COLUMN: return layer.get_alias() @@ -434,6 +447,8 @@ def data(self, index, role=None): return layer.get_muted() if column == self.SOLO_COLUMN: return layer.get_soloed() + if column == self.LOCK_COLUMN: + return is_locked if column == self.HAS_SELECTED_COLUMN: return layer in self.layers_with_selected if column == self.UNSAVED: @@ -450,6 +465,8 @@ def data(self, index, role=None): if column == self.SOLO_COLUMN: soloed = layer.get_soloed() return QtCore.Qt.Checked if soloed else QtCore.Qt.Unchecked + if column == self.LOCK_COLUMN: + return QtCore.Qt.Checked if is_locked else QtCore.Qt.Unchecked def setData(self, index, value, role=QtCore.Qt.EditRole): """Allows editing of layers via qt model interface. @@ -481,12 +498,16 @@ def setData(self, index, value, role=QtCore.Qt.EditRole): if column == self.SOLO_COLUMN: self.stage_model.solo_toggle_layer(layer) return True + if column == self.LOCK_COLUMN: + self.stage_model.set_layer_locked(layer_path, + lock=not layer.get_locked()) + return True return False def flags(self, index): column = index.column() if column in (self.TARGET_COLUMN, self.DISPLAY_COLUMN, - self.MUTE_COLUMN, self.SOLO_COLUMN,): + self.MUTE_COLUMN, self.SOLO_COLUMN, self.LOCK_COLUMN): return (QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsUserCheckable) return (QtCore.Qt.ItemIsEnabled | @@ -580,6 +601,7 @@ def __init__(self): super(AliasDelegate, self).__init__() self.muted = False self.soloed = False + self.locked = False self.is_tgt = False self.has_selected = False self.unsaved = False @@ -639,6 +661,8 @@ def paint(self, painter, option, index): self.muted = model.data(mute_index, role=QtCore.Qt.EditRole) solo_index = model.index(row, LayerModel.SOLO_COLUMN, parent) self.soloed = model.data(solo_index, role=QtCore.Qt.EditRole) + lock_index = model.index(row, LayerModel.LOCK_COLUMN, parent) + self.locked = model.data(lock_index, role=QtCore.Qt.EditRole) tgt_index = model.index(row, LayerModel.TARGET_COLUMN, parent) self.is_tgt = model.data(tgt_index, role=QtCore.Qt.EditRole) sel_idx = model.index(row, LayerModel.HAS_SELECTED_COLUMN, parent) diff --git a/nxt_editor/dockwidgets/property_editor.py b/nxt_editor/dockwidgets/property_editor.py index 7280199..8bb0863 100644 --- a/nxt_editor/dockwidgets/property_editor.py +++ b/nxt_editor/dockwidgets/property_editor.py @@ -52,6 +52,7 @@ def __init__(self, graph_model=None, title='Property Editor', parent=None, self.stage_model = graph_model self.node_path = None self._resolved = True + self.locked = False self.node_path = '' self.node_instance = '' self.node_inst_source = ('', '') @@ -204,15 +205,15 @@ def __init__(self, graph_model=None, title='Property Editor', parent=None, self.instance_layout.addWidget(self.locate_instance_button, 0, 1) self.instance_opinions = OpinionDots(self, 'Instance Opinions') self.instance_layout.addWidget(self.instance_opinions, 0, 2) - self.remove_instance_button = PixmapButton(pixmap=':icons/icons/delete.png', + self.revert_instance_button = PixmapButton(pixmap=':icons/icons/delete.png', pixmap_hover=':icons/icons/delete_hover.png', pixmap_pressed=':icons/icons/delete_pressed.png', size=12, parent=self.properties_frame) - self.remove_instance_button.setToolTip('Revert Instance') - self.remove_instance_button.setStyleSheet('QToolTip {color: white; border: 1px solid #3E3E3E}') - self.remove_instance_button.set_action(self.revert_inst_path_action) - self.instance_layout.addWidget(self.remove_instance_button, 0, 3) + self.revert_instance_button.setToolTip('Revert Instance') + self.revert_instance_button.setStyleSheet('QToolTip {color: white; border: 1px solid #3E3E3E}') + self.revert_instance_button.set_action(self.revert_inst_path_action) + self.instance_layout.addWidget(self.revert_instance_button, 0, 3) # execute in self.execute_label = QtWidgets.QLabel('Exec Input', parent=self) @@ -237,15 +238,15 @@ def __init__(self, graph_model=None, title='Property Editor', parent=None, self.execute_field.setCompleter(self.execute_field_completer) self.execute_opinions = OpinionDots(self, 'Execute Opinions') self.execute_layout.addWidget(self.execute_opinions, 0, 1) - self.remove_exec_source_button = PixmapButton(pixmap=':icons/icons/delete.png', + self.revert_exec_source_button = PixmapButton(pixmap=':icons/icons/delete.png', pixmap_hover=':icons/icons/delete_hover.png', pixmap_pressed=':icons/icons/delete_pressed.png', size=12, parent=self.properties_frame) - self.remove_exec_source_button.setToolTip('Revert Execute Source') - self.remove_exec_source_button.setStyleSheet('QToolTip {color: white; border: 1px solid #3E3E3E}') - self.remove_exec_source_button.set_action(self.revert_exec_path_action) - self.execute_layout.addWidget(self.remove_exec_source_button, 0, 2) + self.revert_exec_source_button.setToolTip('Revert Execute Source') + self.revert_exec_source_button.setStyleSheet('QToolTip {color: white; border: 1px solid #3E3E3E}') + self.revert_exec_source_button.set_action(self.revert_exec_path_action) + self.execute_layout.addWidget(self.revert_exec_source_button, 0, 2) # execute_order self.child_order_label = QtWidgets.QLabel('Child Order', parent=self) @@ -321,17 +322,17 @@ def __init__(self, graph_model=None, title='Property Editor', parent=None, self.position_layout.addWidget(self.enabled_opinions, 0, QtCore.Qt.AlignLeft) icn = ':icons/icons/' - self.revert_enabled = PixmapButton(pixmap=icn+'delete.png', - pixmap_hover=icn+'delete_hover.png', - pixmap_pressed=icn+'delete_pressed.png', - size=12, - parent=self.properties_frame) - self.revert_enabled.setToolTip('Revert Enabled State') - self.revert_enabled.setStyleSheet('QToolTip {color: white; ' + self.revert_enabled_button = PixmapButton(pixmap=icn + 'delete.png', + pixmap_hover=icn+'delete_hover.png', + pixmap_pressed=icn+'delete_pressed.png', + size=12, + parent=self.properties_frame) + self.revert_enabled_button.setToolTip('Revert Enabled State') + self.revert_enabled_button.setStyleSheet('QToolTip {color: white; ' 'order: 1px solid #3E3E3E' '}') - self.revert_enabled.clicked.connect(self.revert_node_enabled) - self.position_layout.addWidget(self.revert_enabled, 0, + self.revert_enabled_button.clicked.connect(self.revert_node_enabled) + self.position_layout.addWidget(self.revert_enabled_button, 0, QtCore.Qt.AlignLeft) self.position_layout.addStretch() @@ -352,15 +353,15 @@ def __init__(self, graph_model=None, title='Property Editor', parent=None, self.comment_layout.addWidget(self.comment_field, 0, 0) self.comment_opinions = OpinionDots(self, 'Comment Opinions', vertical=True) self.comment_layout.addWidget(self.comment_opinions, 0, 1) - self.remove_comment_button = PixmapButton(pixmap=':icons/icons/delete.png', + self.revert_comment_button = PixmapButton(pixmap=':icons/icons/delete.png', pixmap_hover=':icons/icons/delete_hover.png', pixmap_pressed=':icons/icons/delete_pressed.png', size=12, parent=self.properties_frame) - self.remove_comment_button.setToolTip('Revert Comment') - self.remove_comment_button.setStyleSheet('QToolTip {color: white; border: 1px solid #3E3E3E}') - self.remove_comment_button.clicked.connect(self.remove_comment) - self.comment_layout.addWidget(self.remove_comment_button, 0, 2) + self.revert_comment_button.setToolTip('Revert Comment') + self.revert_comment_button.setStyleSheet('QToolTip {color: white; border: 1px solid #3E3E3E}') + self.revert_comment_button.clicked.connect(self.remove_comment) + self.comment_layout.addWidget(self.revert_comment_button, 0, 2) # Comment self.accept_comment_action = self.comment_actions.accept_comment_action self.accept_comment_action.triggered.connect(self.accept_edit_comment) @@ -510,6 +511,7 @@ def set_stage_model(self, stage_model): def set_stage_model_connections(self, model, connect): self.model_signal_connections = [ (model.node_focus_changed, self.set_represented_node), + (model.layer_lock_changed, self.handle_locking), (model.nodes_changed, self.handle_nodes_changed), (model.attrs_changed, self.handle_attrs_changed), (model.data_state_changed, self.update_resolved), @@ -524,6 +526,34 @@ def on_stage_model_destroyed(self): super(PropertyEditor, self).on_stage_model_destroyed() self.properties_frame.hide() + def handle_locking(self, *args): + # TODO: Make it a user pref to lock the property editor when node is locked? + # self.locked = self.stage_model.target_layer.get_locked() + if self.locked: + self.table_view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + else: + self.table_view.setEditTriggers(QtWidgets.QAbstractItemView.DoubleClicked) + # Read only + self.name_label.setReadOnly(self.locked) + self.instance_field.setReadOnly(self.locked) + self.execute_field.setReadOnly(self.locked) + self.child_order_field.setReadOnly(self.locked) + self.positionX_field.setReadOnly(self.locked) + self.positionY_field.setReadOnly(self.locked) + self.comment_field.setReadOnly(self.locked) + # Enable/Disable + self.revert_instance_button.setEnabled(not self.locked) + self.revert_exec_source_button.setEnabled(not self.locked) + self.revert_child_order_button.setEnabled(not self.locked) + self.enabled_checkbox.setEnabled(not self.locked) + self.revert_enabled_button.setEnabled(not self.locked) + self.revert_comment_button.setEnabled(not self.locked) + self.add_attr_button.setEnabled(not self.locked) + self.remove_attr_button.setEnabled(not self.locked) + # Actions + for action in self.authoring_actions.actions() + self._actions.actions() + self.comment_actions.actions(): + action.setEnabled(not self.locked) + def handle_nodes_changed(self, nodes): if self.node_path in nodes: self.set_represented_node() @@ -625,6 +655,7 @@ def set_represented_node(self): # update attribute model self.model.set_represented_node(node_path=self.node_path) + self.handle_locking() def view_instance_node(self): instance_path = self.instance_field.text() @@ -653,6 +684,10 @@ def focus_instance_field(self, in_focus): expand=expand) if comp_path != path: path = comp_path + if in_focus: + self.instance_field.focus_in_val = path + else: + self.instance_field.focus_in_val = '' self.instance_field.setText(path) def update_properties(self): @@ -774,12 +809,12 @@ def display_properties(self): self.instance_label.setVisible(not is_world) self.instance_field.setVisible(not is_world) self.locate_instance_button.setVisible(not is_world) - self.remove_instance_button.setVisible(not is_world) + self.revert_instance_button.setVisible(not is_world) self.instance_opinions.setVisible(not is_world) self.execute_field.setVisible(is_top) self.execute_label.setVisible(is_top) - self.remove_exec_source_button.setVisible(is_top) + self.revert_exec_source_button.setVisible(is_top) self.execute_opinions.setVisible(not is_world) @@ -794,7 +829,7 @@ def display_properties(self): self.enabled_checkbox.setVisible(not is_world) self.enabled_checkbox_label.setVisible(not is_world) - self.revert_enabled.setVisible(not is_world) + self.revert_enabled_button.setVisible(not is_world) self.enabled_opinions.setVisible(not is_world) def edit_name(self, new_name): @@ -815,6 +850,7 @@ def edit_instance(self): cur_inst_path = self.stage_model.get_node_instance_path(self.node_path, lookup_layer, expand=False) + cur_inst_path = str(self.instance_field.focus_in_val) instance_path = str(self.instance_field.text()) if (not cur_inst_path and not instance_path or cur_inst_path == instance_path): @@ -880,6 +916,8 @@ def revert_node_enabled(self): self.stage_model.revert_node_enabled(self.node_path) def edit_position(self): + if self.locked: + return x = self.positionX_field.value() y = self.positionY_field.value() if not self.node_path or not self.stage_model.node_exists(self.node_path, self.stage_model.comp_layer): @@ -1026,8 +1064,9 @@ def custom_context_menu(self, pos): self.stage_model) menu.popup(QtGui.QCursor.pos()) - @staticmethod - def reset_action_enabled(actions): + def reset_action_enabled(self, actions): + if self.locked: + return for action in actions: action.setEnabled(True) @@ -1061,12 +1100,9 @@ def exec_context_menu(self): INTERNAL_ATTRS.EXECUTE_IN, self.stage_model.comp_layer) tgt_path = self.stage_model.target_layer.real_path - if src_path == self.node_path and layer == tgt_path: - self.localize_exec_path_action.setEnabled(False) - self.revert_exec_path_action.setEnabled(True) - else: - self.localize_exec_path_action.setEnabled(True) - self.revert_exec_path_action.setEnabled(False) + exec_is_path_local = (src_path == self.node_path) and (layer == tgt_path) + self.localize_exec_path_action.setEnabled(not exec_is_path_local and not self.locked) + self.revert_exec_path_action.setEnabled(exec_is_path_local and not self.locked) link_to = HistoricalContextMenu.LINKS.SOURCE historical_menu = HistoricalContextMenu(self, self.node_path, INTERNAL_ATTRS.EXECUTE_IN, @@ -1686,6 +1722,7 @@ class LineEdit(QtWidgets.QLineEdit): def __init__(self, parent=None): # Cheat because hasFocus is the parent not the actual line self.has_focus = False + self.focus_in_val = '' super(LineEdit, self).__init__(parent) def keyPressEvent(self, event): diff --git a/nxt_editor/label_edit.py b/nxt_editor/label_edit.py index 389d46b..f055a0f 100644 --- a/nxt_editor/label_edit.py +++ b/nxt_editor/label_edit.py @@ -11,12 +11,21 @@ class LabelEdit(QtWidgets.QLabel): def __init__(self, *args, **kwargs): super(LabelEdit, self).__init__(*args, **kwargs) self.doubleClicked.connect(self.edit_text) + self._read_only = False + + def setReadOnly(self, state): + """Named this way to mimic Qt""" + self._read_only = state def mouseDoubleClickEvent(self, event): + if self._read_only: + return if event.button() == QtCore.Qt.LeftButton: self.doubleClicked.emit() def edit_text(self): + if self._read_only: + return # get current name name = self.text() diff --git a/nxt_editor/main_window.py b/nxt_editor/main_window.py index 27e87b1..3dbb666 100644 --- a/nxt_editor/main_window.py +++ b/nxt_editor/main_window.py @@ -460,6 +460,7 @@ def new_tab(self, initial_stage=None, update=True): # create model model = StageModel(stage=stage) model.processing.connect(self.set_waiting_cursor) + model.request_ding.connect(self.ding) model.layer_alias_changed.connect(partial(self.update_tab_title, model)) # create view view = StageView(model=model, parent=self) @@ -479,6 +480,11 @@ def new_tab(self, initial_stage=None, update=True): self.update() # TODO: Make this better self.set_waiting_cursor(False) + @staticmethod + def ding(): + if user_dir.user_prefs.get(user_dir.USER_PREF.DING, True): + QtWidgets.QApplication.instance().beep() + def center_view(self): target_graph_view = self.get_current_view() if target_graph_view: @@ -1011,13 +1017,13 @@ class MenuBar(QtWidgets.QMenuBar): def __init__(self, parent=None): super(MenuBar, self).__init__(parent=parent) self.main_window = parent - self.app_actions = parent.app_actions - self.exec_actions = parent.execute_actions - self.node_actions = parent.node_actions - self.ce_actions = parent.code_editor_actions - self.display_actions = parent.display_actions - self.view_actions = parent.view_actions - self.layer_actions = parent.layer_actions + self.app_actions = parent.app_actions # type: actions.AppActions + self.exec_actions = parent.execute_actions # type: actions.ExecuteActions + self.node_actions = parent.node_actions # type: actions.NodeActions + self.ce_actions = parent.code_editor_actions # type: actions.CodeEditorActions + self.display_actions = parent.display_actions # type: actions.DisplayActions + self.view_actions = parent.view_actions # type: actions.StageViewActions + self.layer_actions = parent.layer_actions # type: actions.LayerActions # File Menu self.file_menu = self.addMenu('File') self.file_menu.setTearOffEnabled(True) @@ -1141,6 +1147,11 @@ def __init__(self, parent=None): self.remote_menu.addSeparator() self.remote_menu.addAction(self.exec_actions.startup_rpc_action) self.remote_menu.addAction(self.exec_actions.shutdown_rpc_action) + self.options_menu = self.addMenu('Options') + self.options_menu.addAction(self.app_actions.toggle_ding_action) + self.options_view_sub = self.options_menu.addMenu('View') + self.options_view_sub.setTearOffEnabled(True) + self.options_view_sub.addActions(self.view_opt_menu.actions()) # Help Menu self.help_menu = self.addMenu('Help') self.help_menu.setTearOffEnabled(True) @@ -1160,8 +1171,6 @@ def __init__(self, parent=None): # Secret Menu self.secret_menu = self.help_menu.addMenu('Developer Options') self.secret_menu.setTearOffEnabled(True) - test_graph_action = self.secret_menu.addAction('Build test nodes') - test_graph_action.triggered.connect(self.build_test_graph) test_log_action = self.secret_menu.addAction('test logging') test_log_action.triggered.connect(self.__test_all_logging) print_action = self.secret_menu.addAction('test print') @@ -1300,25 +1309,6 @@ def delete_resources_pyc(): from . import make_resources make_resources() - def build_test_graph(self): - target_model = self.parent().model - x = 0 - y = 0 - previous_node_path = None - for i in xrange(0, 10): - new_node_path = target_model.add_node(name='test_node') - x += 300 - if i == 5: - y += 250 - x = 0 - target_model.set_nodes_pos({new_node_path: [x, y]}) - if previous_node_path: - target_model.set_node_exec_in(new_node_path, previous_node_path) - previous_node_path = new_node_path - for _ in xrange(0, 4): - target_model.add_node_attr(new_node_path) - target_model.frame_items.emit((new_node_path,)) - def __test_print(self): """prints a simple message for output log debug""" print('Test print please ignore') diff --git a/nxt_editor/node_graphics_item.py b/nxt_editor/node_graphics_item.py index 4e4f842..cd92d78 100644 --- a/nxt_editor/node_graphics_item.py +++ b/nxt_editor/node_graphics_item.py @@ -42,6 +42,8 @@ class NodeGraphicsItem(graphic_type): ATTR_PLUG_RADIUS = 4 EXEC_PLUG_RADIUS = 6 + ROUND_X = 2. + ROUND_Y = 2. def __init__(self, model, node_path, view): super(NodeGraphicsItem, self).__init__() @@ -78,6 +80,7 @@ def __init__(self, model, node_path, view): self.is_start = False self.start_color = colors.ERROR self.is_proxy = False + self.locked = False self.is_real = True self.attr_dots = [False, False, False] self.error_list = [] @@ -315,17 +318,24 @@ def draw_border(self, painter, lod=1.): else: painter.setPen(QtCore.Qt.NoPen) return - color = self.colors[-1].darker(self.dim_factor) if self.is_proxy: pen = QtGui.QPen(color, 1, QtCore.Qt.PenStyle.DashLine) else: pen = QtGui.QPen(color) - painter.setPen(pen) - painter.setBrush(QtCore.Qt.NoBrush) - painter.drawRect(QtCore.QRectF(self.get_selection_rect().x() + 1, - self.get_selection_rect().y() + 1, - self.get_selection_rect().width() - 2, - self.get_selection_rect().height() - 2)) + if self.locked: + c = QtGui.QColor(self.colors[-1]) + c.setAlphaF(.3) + b = QtGui.QBrush(c) + painter.setBrush(b) + painter.setPen(QtCore.Qt.NoPen) + else: + painter.setPen(pen) + painter.setBrush(QtCore.Qt.NoBrush) + rect = QtCore.QRectF(self.get_selection_rect().x() + 1, + self.get_selection_rect().y() + 1, + self.get_selection_rect().width() - 2, + self.get_selection_rect().height() - 2) + painter.drawRoundedRect(rect, self.ROUND_X, self.ROUND_Y) def draw_title(self, painter, lod=1.): """Draw title of the node. Called exclusively in paint. @@ -341,7 +351,7 @@ def draw_title(self, painter, lod=1.): self.scene().removeItem(self.error_item) self.error_item.deleteLater() self.error_item = None - if self.is_real: + if self.is_real and not self.locked: painter.setBackgroundMode(QtCore.Qt.OpaqueMode) else: painter.setBackgroundMode(QtCore.Qt.TransparentMode) @@ -354,19 +364,26 @@ def draw_title(self, painter, lod=1.): painter.setBackground(color.darker(self.dim_factor)) brush = QtGui.QBrush(color.darker(self.dim_factor*2), QtCore.Qt.FDiagPattern) - painter.setBrush(brush) else: - painter.setBrush(color.darker(self.dim_factor)) + brush = QtGui.QBrush(color.darker(self.dim_factor)) + if self.locked: + c = color.darker(self.dim_factor) + c.setAlphaF(.5) + painter.setBackground(c) + brush = QtGui.QBrush(c.darker(self.dim_factor * 2), + QtCore.Qt.Dense1Pattern) + painter.setBrush(brush) # Top Opinion if i+1 == color_count: remaining_width = self.max_width - (i*color_band_width) - painter.drawRect(0, 0, remaining_width, - self.title_rect_height) + rect = QtCore.QRectF(0, 0, remaining_width, + self.title_rect_height) # Lower Opinions else: x_pos = self.max_width - (i+1)*color_band_width - painter.drawRect(x_pos, 0, color_band_width, - self.title_rect_height) + rect = QtCore.QRectF(x_pos, 0, color_band_width, + self.title_rect_height) + painter.drawRoundedRect(rect, self.ROUND_X, self.ROUND_Y) painter.setBackground(bg) painter.setBackgroundMode(bgm) # draw exec plugs @@ -703,6 +720,7 @@ def update_from_model(self): self.setPos(pos[0], pos[1]) self.is_real = self.model.node_exists(node_path) self.is_proxy = self.model.get_node_is_proxy(node_path) + self.locked = self.model.get_node_locked(node_path) self.collapse_state = self.model.get_node_collapse(self.node_path, comp) self.node_enabled = self.model.get_node_enabled(self.node_path) diff --git a/nxt_editor/stage_model.py b/nxt_editor/stage_model.py index 5933a9b..0b27cc4 100644 --- a/nxt_editor/stage_model.py +++ b/nxt_editor/stage_model.py @@ -25,7 +25,7 @@ get_node_enabled) from nxt.stage import (determine_nxt_type, INTERNAL_ATTRS, get_historical_opinions) -from nxt.runtime import GraphError, InvalidNodeError +from nxt.runtime import ExitGraph, GraphError, InvalidNodeError from nxt_editor.dialogs import NxtConfirmDialog, NxtWarningDialog from nxt.remote import nxt_socket @@ -59,6 +59,7 @@ class StageModel(QtCore.QObject): layer_mute_changed = QtCore.Signal(tuple) # Layer paths whose mute changed layer_solo_changed = QtCore.Signal(tuple) # Layer paths whose solo changed layer_alias_changed = QtCore.Signal(str) # Layer path whose alias changed + layer_lock_changed = QtCore.Signal(str) # Layer path whose locked changed layer_removed = QtCore.Signal(str) # Layer path who was removed layer_added = QtCore.Signal(str) # Layer path who was added layer_saved = QtCore.Signal(str) # Layer path that was just saved @@ -76,12 +77,13 @@ class StageModel(QtCore.QObject): collapse_changed = QtCore.Signal(tuple) # node paths where changed frame_items = QtCore.Signal(tuple) server_log = QtCore.Signal(str) + request_ding = QtCore.Signal() def __init__(self, stage): super(StageModel, self).__init__() self.stage = stage self.clipboard = QtWidgets.QApplication.clipboard() - self.undo_stack = QtWidgets.QUndoStack(self) + self.undo_stack = NxtUndoStack(self) self.effected_layers = UnsavedLayerSet() # execution @@ -507,11 +509,19 @@ def update_comp_layer(self, rebuild=False, dirty=()): def target_layer(self): return self._target_layer + def _set_target_layer(self, layer): + self._target_layer = layer + self.target_layer_changed.emit(self.target_layer) + def set_target_layer(self, layer_path): layer = self.lookup_layer(layer_path) - if layer: - self._target_layer = layer - self.target_layer_changed.emit(self.target_layer) + if not layer: + return + if layer.get_locked(): + logger.warning('"{}" is a locked layer!'.format(layer.alias)) + self.request_ding.emit() + return + self._set_target_layer(layer) def set_layer_alias(self, alias, layer): layer_path = self.get_layer_path(layer, fallback=LAYERS.TARGET) @@ -576,6 +586,36 @@ def get_layer_color(self, layer, local=False): return layer_color return color + def get_layer_locked(self, layer_path): + layer = self.lookup_layer(layer_path) + return layer.get_locked() + + def set_layer_locked(self, layer_path, lock=None): + """Sets the layer lock for the given layer. If lock is set to None + (default) it will not be serialized and the graph default lock for + this layer's index will be used. + + :param layer_path: layer real path + :type layer_path: str + :param lock: lock state + :type lock: bool or None + """ + layer = self.lookup_layer(layer_path) + if not layer: + logger.error('Cannot set lock for invalid layer: {}'.format(layer)) + return + if layer is self.top_layer: + logger.warning('Cannot lock top layer!') + self.request_ding.emit() + return + else: + cur_lock = layer.get_locked() + if cur_lock == lock: + logger.error('{} lock is already {}'.format(layer, lock)) + return + cmd = SetLayerLock(lock, layer_path, self) + self.undo_stack.push(cmd) + def get_layer(self, layer_alias): """Gets a layer via its alias. :param layer_alias: @@ -628,6 +668,20 @@ def get_layer_path(layer, fallback=None): def get_is_layer_soloed(layer): return layer.get_soloed() + def get_node_locked(self, node_path, local=False, layer_opinion=True): + # TODO: Make it so nodes can be locked locally + local_lock = False + if local_lock is not None and all((local, not layer_opinion)): + return local_lock + node = self.comp_layer.lookup(node_path) + if not node: + return False + src_layer = self.get_node_source_layer(node_path) + locked = src_layer.get_locked() + if locked is None: + return local_lock + return locked + def is_top_node(self, node_path): parent_path = nxt_path.get_parent_path(node_path) if parent_path != nxt_path.WORLD: @@ -1984,6 +2038,10 @@ def set_node_exec_in(self, node_path, source_node_path, layer=None): if source_node_path == nxt_path.WORLD: logger.error("Cannot set node exec in to the world") return + if source_node_path in self.get_exec_order(node_path): + logger.error('Cannot connect exec in from {} (would cycle)'.format(source_node_path), + links=[source_node_path]) + return layer_path = self.get_layer_path(layer, fallback=LAYERS.TARGET) node_ns = nxt_path.str_path_to_node_namespace(node_path) if len(node_ns) > 1: @@ -3142,6 +3200,24 @@ def process_events(): QtCore.QCoreApplication.processEvents() +class NxtUndoStack(QtWidgets.QUndoStack): + + def push(self, command): + """Simple overload of push method, checks that the target layer of the given command's model is *not* locked. + If the command does not have a model attr nothing is checked. + + :param command: Command to push to undo stack + :type command: QUndoCommand + :return: None + """ + model = getattr(command, 'model', None) # type: StageModel + if model and model.target_layer.get_locked(): + logger.warning('The target layer is locked!') + model.request_ding.emit() + return + super(NxtUndoStack, self).push(command) + + class UnsavedLayerSet(set): def __init__(self): @@ -3505,5 +3581,8 @@ def _run(self): else: self.raised_exception = BuildStop return + except ExitGraph: + self.raised_exception = BuildStop + return if self.stage_model._build_should_stop: self.raised_exception = BuildStop diff --git a/nxt_editor/stage_view.py b/nxt_editor/stage_view.py index 981a45d..d362aa8 100644 --- a/nxt_editor/stage_view.py +++ b/nxt_editor/stage_view.py @@ -92,6 +92,7 @@ def __init__(self, model, parent=None): self._held_keys = [] self._rubber_band_origin = None self._initial_click_pos = None + self._clicked_something_locked = False self.new_node_selected = False self.panning = False self.zooming = False @@ -128,6 +129,7 @@ def __init__(self, model, parent=None): self.model = model self.model.data_state_changed.connect(self.update_resolved) self.model.layer_color_changed.connect(self.update_view) + self.model.layer_lock_changed.connect(self.update_view) self.model.comp_layer_changed.connect(self.update_view) self.model.comp_layer_changed.connect(self.failure_check) self.model.nodes_changed.connect(self.handle_nodes_changed) @@ -694,6 +696,7 @@ def keyReleaseEvent(self, event): def mousePressEvent(self, event): # capture initial click position which is used in the release event + self._clicked_something_locked = False self._initial_click_pos = event.pos() self.zoom_button_down = event.button() is self.zoom_button self.zooming = False @@ -724,6 +727,9 @@ def mousePressEvent(self, event): # Any click on a graphics item that isn't selectable super(StageView, self).mousePressEvent(event) return + not_intractable = self.model.get_node_locked(item_path) + if not_intractable: + self._clicked_something_locked = True # item interaction curr_sel = self.model.is_selected(item_path) mods = event.modifiers() @@ -805,8 +811,14 @@ def mouseMoveEvent(self, event): self._previous_mouse_pos = event.pos() event.accept() return - super(StageView, self).mouseMoveEvent(event) + item = self.itemAt(event.pos()) + app = QtWidgets.QApplication + if item and hasattr(item, 'locked') and item.locked: + if not app.overrideCursor(): + app.setOverrideCursor(QtCore.Qt.ForbiddenCursor) + else: + app.restoreOverrideCursor() def mouseReleaseEvent(self, event): was_just_zooming = self.zooming @@ -905,11 +917,14 @@ def mouseReleaseEvent(self, event): if type(items_released_on[1]) is NodeGraphicsPlug: dropped_plug = items_released_on[1] dropped_node_path = dropped_plug.parentItem().node_path + locked = self.model.get_node_locked(dropped_node_path) dropped_attr_name = dropped_plug.attr_name_represented exec_attr_name = nxt_node.INTERNAL_ATTRS.EXECUTE_IN - if dropped_attr_name not in nxt_node.INTERNAL_ATTRS.ALL: + if (dropped_attr_name not in nxt_node.INTERNAL_ATTRS.ALL + and not locked): if self.potential_connection.src_path: if dropped_plug.is_input: + # Fixme: This isn't how tokens are created now value = '${%s}' % self.potential_connection.src_path self.model.set_node_attr_value(node_path=dropped_node_path, attr_name=dropped_attr_name, @@ -928,17 +943,19 @@ def mouseReleaseEvent(self, event): layer=self.model.target_layer) else: logger.warning("cannot make connections from input to inputs") - elif dropped_attr_name == exec_attr_name: + elif dropped_attr_name == exec_attr_name and not locked: src_path = self.potential_connection.src_node_path tgt_path = self.potential_connection.tgt_node_path - if src_path: + locked = all((not locked, + self.model.get_node_locked(tgt_path))) + if src_path and not locked: if dropped_plug.is_input: self.model.set_node_exec_in(node_path=dropped_node_path, source_node_path=src_path, layer=self.model.target_layer) else: logger.warning("cannot make connections from output to output.") - elif tgt_path: + elif tgt_path and not locked: if not dropped_plug.is_input: self.model.set_node_exec_in(node_path=tgt_path, source_node_path=dropped_node_path, diff --git a/nxt_editor/test/test_stage_model.py b/nxt_editor/test/test_stage_model.py index b66f8ca..ddcdfbd 100644 --- a/nxt_editor/test/test_stage_model.py +++ b/nxt_editor/test/test_stage_model.py @@ -75,3 +75,24 @@ def test_instance_node_attrs(self): self.assertEqual(child_expected_local, child_locals) self.assertEqual(child_expected_inherit, child_inherit) self.assertEqual(child_expected_inst, child_inst) + + +class ExecOrderCycle(unittest.TestCase): + + @classmethod + def setUpClass(cls): + os.chdir(os.path.dirname(__file__)) + cls.stage = Session().load_file(filepath="StageInstanceTest.nxt") + cls.model = stage_model.StageModel(cls.stage) + cls.comp_layer = cls.model.comp_layer + + def test_local_node_attrs(self): + node_path1 = '/inst_source4' + node_path2 = '/inst_target4' + print("Testing that {} has no exec in set".format(node_path1)) + node1_exec_in = self.model.get_node_exec_in(node_path1) + self.assertIsNone(node1_exec_in) + print("Testing that {} can't have its exec in set to {}".format(node_path1, node_path2)) + self.model.set_node_exec_in(node_path1, node_path2) + node1_exec_in = self.model.get_node_exec_in(node_path1) + self.assertIsNone(node1_exec_in) diff --git a/nxt_editor/user_dir.py b/nxt_editor/user_dir.py index be3acc9..82ee6af 100644 --- a/nxt_editor/user_dir.py +++ b/nxt_editor/user_dir.py @@ -73,6 +73,7 @@ class USER_PREF(): ANIMATION = 'animation' SHOW_DBL_CLICK_MSG = 'show_double_click_message' SHOW_CE_DATA_STATE = 'show_code_editor_data_state' + DING = 'ding' class EDITOR_CACHE(): diff --git a/nxt_editor/version.json b/nxt_editor/version.json index e7168aa..bf12d59 100644 --- a/nxt_editor/version.json +++ b/nxt_editor/version.json @@ -1,7 +1,7 @@ { "EDITOR": { "MAJOR": 3, - "MINOR": 10, + "MINOR": 11, "PATCH": 0 } } diff --git a/setup.py b/setup.py index 0e2135a..ad937f1 100644 --- a/setup.py +++ b/setup.py @@ -1,11 +1,11 @@ import setuptools import json import os - +import io this_dir = os.path.dirname(os.path.realpath(__file__)) module_dir = os.path.join(this_dir, 'nxt_editor') -with open(os.path.join(this_dir, "README.md"), "r") as fp: +with io.open(os.path.join(this_dir, "README.md"), "r", encoding="utf-8") as fp: long_description = fp.read() desc = ("A general purpose code compositor designed for rigging, " @@ -29,7 +29,7 @@ url="https://github.com/nxt-dev/nxt_editor", packages=setuptools.find_packages(), python_requires='>=2.7, <3.8', - install_requires=['nxt-core', + install_requires=['nxt-core<1.0,>=0.13', 'qt.py==1.1', 'pyside2==5.11.1' ],