diff --git a/.travis.yml b/.travis.yml index e1660b4..2f1adc5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,16 +6,10 @@ branches: python: - "3.7" -services: - - xvfb - -env: - - QT_CI_PACKAGES=qt.qt5.5132.gcc_64,qt.qt5.5132.qtwebengine PATH="$TRAVIS_BUILD_DIR/Qt/5.13.2/gcc_64/bin:${PATH}" - before_install: - | if [ "$TRAVIS_BRANCH" = "dev" ] && [ "$TRAVIS_EVENT_TYPE" = "push" ]; then - echo "Push to dev, not running tests until PR" + echo "Push to dev, not running until push to release" exit 0 else echo "Doing the build" @@ -24,20 +18,13 @@ before_install: install: - cd .. - git clone https://github.com/nxt-dev/nxt.git - - pip install ./nxt_editor - pip install importlib-metadata==3.4 - - pip install twine - - sudo apt-get install -y libxkbcommon-x11-0 - - sudo apt-get install -y libgl1-mesa-dev + - pip install ./nxt_editor script: - - | - if [ "$TRAVIS_BRANCH" = "release" ] && [ "$TRAVIS_EVENT_TYPE" = "pull_request" ]; then - python -m nxt.cli test - exit $? - fi - | if [ "$TRAVIS_BRANCH" = "release" ] && [ "$TRAVIS_EVENT_TYPE" = "push" ]; then + pip install twine python -m nxt.cli exec nxt_editor/build/packaging.nxt -s /make_and_upload exit $? fi \ No newline at end of file diff --git a/nxt_editor/__init__.py b/nxt_editor/__init__.py index a9d778a..cf2523c 100644 --- a/nxt_editor/__init__.py +++ b/nxt_editor/__init__.py @@ -38,7 +38,8 @@ class StringSignaler(QtCore.QObject): def make_resources(qrc_path=None, result_path=None): import PySide2 pyside_dir = os.path.dirname(PySide2.__file__) - full_rcc_path = os.path.join(pyside_dir, 'pyside2-rcc') + full_pyside2rcc_path = os.path.join(pyside_dir, 'pyside2-rcc') + full_rcc_path = os.path.join(pyside_dir, 'rcc') this_dir = os.path.dirname(os.path.realpath(__file__)) if not qrc_path: qrc_path = os.path.join(this_dir, 'resources/resources.qrc') @@ -59,17 +60,26 @@ def make_resources(qrc_path=None, result_path=None): return try: - subprocess.check_call([full_rcc_path] + args) + subprocess.check_call([full_pyside2rcc_path] + args) + except: + pass + else: + return + try: + subprocess.check_call([full_rcc_path, '-g', 'python', qrc_path, + '-o', result_path], cwd=pyside_dir) except: pass else: return - try: - subprocess.check_call(['rcc', '-g', 'python', qrc_path, result_path]) + subprocess.check_call(['rcc', '-g', 'python', qrc_path, + '-o', result_path], cwd=pyside_dir) except: - raise Exception("Cannot find pyside2 rcc to generate UI resources." - " Reinstalling pyside2 may fix the problem.") + raise Exception("Failed to generate UI resources using pyside2 rcc!" + " Reinstalling pyside2 may fix the problem. If you " + "know how to use rcc please build from: \"{}\" and " + "output to \"{}\"".format(qrc_path, result_path)) else: return @@ -82,11 +92,16 @@ def make_resources(qrc_path=None, result_path=None): def _new_qapp(): - app = QtWidgets.QApplication + app = QtWidgets.QApplication.instance() + create_new = False + if not app: + app = QtWidgets.QApplication + app.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling, True) + create_new = True os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" - app.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling, True) app.setEffectEnabled(QtCore.Qt.UI_AnimateCombo, False) - app = app(sys.argv) + if create_new: + app = app(sys.argv) style_file = QtCore.QFile(':styles/styles/dark/dark.qss') style_file.open(QtCore.QFile.ReadOnly | QtCore.QFile.Text) stream = QtCore.QTextStream(style_file) diff --git a/nxt_editor/actions.py b/nxt_editor/actions.py index 5b9c8e9..fdd76e4 100644 --- a/nxt_editor/actions.py +++ b/nxt_editor/actions.py @@ -160,7 +160,26 @@ def handle_model_change(self): class BoolUserPrefAction(NxtAction): + """NxtAction that saves state between sessions via preference key.""" def __init__(self, text, pref_key, default=False, parent=None): + """NxtAction that saves state between sessions via preference key + + Note that default checked state is set during initialization, before + signals have been hooked up. Meaning any later-connected function is not + automatically called to "kick start" the application state. Either + build that into the start of the UI, or call your action once manually + to kick start. + + :param text: Action description. + :type text: str + :param pref_key: Preference key to save at + :type pref_key: str + :param default: Deafult value, loads default state from user pref, only + falling back to this default when no save exists, defaults to False + :type default: bool, optional + :param parent: Action parent, defaults to None + :type parent: QObject, optional + """ super(BoolUserPrefAction, self).__init__(text, parent) self.setCheckable(True) self.pref_key = pref_key @@ -1167,13 +1186,13 @@ def toggle_grid(): state = self.grid_action.isChecked() self.main_window.view.toggle_grid(state) - self.grid_action = NxtAction(text='Toggle Grid', - parent=self) + self.grid_action = BoolUserPrefAction('Toggle Grid', + user_dir.USER_PREF.SHOW_GRID, + default=True, + parent=self) self.grid_action.setShortcut('Ctrl+;') self.grid_action.setToolTip('Show / Hide the Grid') self.grid_action.setWhatsThis('Shows or hides the grid for all tabs.') - self.grid_action.setCheckable(True) - self.grid_action.setChecked(True) self.grid_action.triggered.connect(toggle_grid) grid_icon = QtGui.QIcon() grid_icn_on = QtGui.QPixmap(':icons/icons/grid_pressed.png') @@ -1585,6 +1604,44 @@ def toggle_breakpoints(): self.run_build_action.setWhatsThis('Runs build specified by the ' 'current value of build view.') + self.toggle_skip_action = NxtAction(text='Toggle Skippoint', + parent=self) + self.toggle_skip_action.setWhatsThis('Toggle skippoint on the selected' + 'node(s)') + self.toggle_skip_action.setAutoRepeat(False) + self.toggle_skip_action.setShortcut('X') + + def toggle_skip(): + node_paths = self.toggle_skip_action.data() + self.toggle_skip_action.setData(None) + if not node_paths: + node_paths = self.main_window.model.get_selected_nodes() + if not node_paths: + logger.info("No nodes to toggle skip on.") + return + layer = self.main_window.model.top_layer.real_path + self.main_window.model.toggle_skippoints(node_paths, layer) + self.toggle_skip_action.triggered.connect(toggle_skip) + + self.set_descendent_skips = NxtAction('Toggle Skip with Descendants', + parent=self) + self.set_descendent_skips.setWhatsThis('Toggles skip of the selected ' + 'node(s), applying the ' + 'skip state to all descendents') + self.set_descendent_skips.setAutoRepeat(False) + self.set_descendent_skips.setShortcut('Shift+X') + + def toggle_descendant_skips(): + node_paths = self.set_descendent_skips.data() + self.set_descendent_skips.setData(None) + if not node_paths: + node_paths = self.main_window.model.get_selected_nodes() + if not node_paths: + logger.info("No nodes to toggle skip on.") + return + self.main_window.model.toggle_descendant_skips(node_paths) + self.set_descendent_skips.triggered.connect(toggle_descendant_skips) + def stop(): self.main_window.model.stop_build() self.stop_exec_action = NxtAction(text='Stop Execution', parent=self) diff --git a/nxt_editor/colors.py b/nxt_editor/colors.py index 309ee39..9874954 100644 --- a/nxt_editor/colors.py +++ b/nxt_editor/colors.py @@ -17,6 +17,11 @@ 'dict': QColor('#984dab'), } +GRAPH_BG_COLOR = QColor(35, 35, 35) +START_COLOR = QColor("#1bd40b") +SKIP_COLOR = QColor("#f0880a") +BREAK_COLOR = QColor(255, 0, 0) + LAYER_COLORS = [ QColor('#991C24'), # dark red QColor('#C91781'), # fuschia diff --git a/nxt_editor/commands.py b/nxt_editor/commands.py index 7339801..b2c75cd 100644 --- a/nxt_editor/commands.py +++ b/nxt_editor/commands.py @@ -1285,6 +1285,45 @@ def redo(self): self.setText("Set {} exec input to {}".format(self.node_path, val)) +class SetNodesAreSkipPoints(QUndoCommand): + + """Set nodes as skip points""" + + def __init__(self, node_paths, to_skip, layer_path, model): + super(SetNodesAreSkipPoints, self).__init__() + self.node_paths = node_paths + self.to_skip = to_skip + self.model = model + self.layer_path = layer_path + + @processing + def redo(self): + if self.to_skip: + func = self.model._add_skippoint + else: + func = self.model._remove_skippoint + for node_path in self.node_paths: + func(node_path, self.layer_path) + self.model.nodes_changed.emit(tuple(self.node_paths)) + if len(self.node_paths) == 1: + path_str = self.node_paths[0] + else: + path_str = "Multiple nodes" + if self.to_skip: + self.setText("Add skippoint to {}".format(path_str)) + else: + self.setText("Remove skippoint from {}".format(path_str)) + + @processing + def undo(self): + if not self.to_skip: + func = self.model._add_skippoint + else: + func = self.model._remove_skippoint + for node_path in self.node_paths: + func(node_path, self.layer_path) + + class SetNodeBreakPoint(QUndoCommand): """Set node as a break point""" diff --git a/nxt_editor/dockwidgets/build_view.py b/nxt_editor/dockwidgets/build_view.py index 935c332..63efa8d 100644 --- a/nxt_editor/dockwidgets/build_view.py +++ b/nxt_editor/dockwidgets/build_view.py @@ -1,6 +1,7 @@ # Builtin import logging import time +from turtle import color # External from Qt import QtWidgets, QtGui, QtCore @@ -10,6 +11,7 @@ from nxt import nxt_path from nxt_editor.dockwidgets.layer_manager import LetterCheckboxDelegeate import nxt_editor +from nxt_editor import colors logger = logging.getLogger(nxt_editor.LOGGER_NAME) @@ -320,7 +322,11 @@ def __init__(self): self.break_delegate = LetterCheckboxDelegeate('B') self.setItemDelegateForColumn(BuildModel.BREAK_COLUMN, self.break_delegate) + self.skip_delegate = LetterCheckboxDelegeate('S') + self.setItemDelegateForColumn(BuildModel.SKIP_COLUMN, + self.skip_delegate) self.clicked.connect(self.on_row_clicked) + # TODO context menu and shift-click for +descendents. def on_row_clicked(self, clicked_idx): """When a row in the table is clicked, select and frame. @@ -349,7 +355,7 @@ def setModel(self, model): def on_build_idx_changed(self, build_idx): if self.model().stage_model.can_build_run(): - model_index = self.model().index(build_idx + 1, 0) + model_index = self.model().index(build_idx, 0) else: model_index = self.model().index(0, 0) self.scrollTo(model_index, self.ScrollHint.PositionAtCenter) @@ -359,19 +365,21 @@ class BuildModel(QtCore.QAbstractTableModel): """A model of a series of nodes that reflects execution information including break status, start status, and which node is next to be run. """ - BREAK_COLUMN = 0 - START_COLUMN = 1 - PATH_COLUMN = 2 - NEXT_RUN_COLUMN = 3 + SKIP_COLUMN = 0 + BREAK_COLUMN = 1 + START_COLUMN = 2 + PATH_COLUMN = 3 + NEXT_RUN_COLUMN = 4 def __init__(self, stage_model): super(BuildModel, self).__init__() """self._nodes is the execute order this build model with reflect answers about. """ - self.headers = ['Break', 'Start', 'Path', 'Next'] + self.headers = ['Skip', 'Break', 'Start', 'Path', 'Next'] self.stage_model = stage_model self._nodes = [] + self.stage_model.skips_changed.connect(self.on_skips_changed) self.stage_model.breaks_changed.connect(self.on_breaks_changed) self.stage_model.build_idx_changed.connect(self.on_build_idx_changed) @@ -389,6 +397,11 @@ def nodes(self, val): self._nodes = val self.endResetModel() + def on_skips_changed(self, new_skips): + last_row = len(self.nodes) - 1 + self.dataChanged.emit(self.index(0, self.SKIP_COLUMN), + self.index(last_row, self.SKIP_COLUMN)) + def on_breaks_changed(self, new_breaks): last_row = len(self.nodes) - 1 self.dataChanged.emit(self.index(0, self.BREAK_COLUMN), @@ -412,7 +425,7 @@ def rowCount(self, index): return len(self.nodes) def columnCount(self, index): - return 4 + return len(self.headers) def data(self, index, role=None): row = index.row() @@ -423,23 +436,28 @@ def data(self, index, role=None): return idx_path next_run = False if self.stage_model.is_build_setup(): - next_run = row == self.stage_model.last_built_idx + 1 + next_run = row == self.stage_model.last_built_idx elif row == 0: next_run = True is_start = self.stage_model.get_is_node_start(idx_path) is_break = self.stage_model.get_is_node_breakpoint(idx_path) + is_skip = self.stage_model.is_node_skippoint(idx_path) if role == QtCore.Qt.CheckStateRole: if column == self.START_COLUMN: return QtCore.Qt.Checked if is_start else QtCore.Qt.Unchecked if column == self.BREAK_COLUMN: return QtCore.Qt.Checked if is_break else QtCore.Qt.Unchecked + if column == self.SKIP_COLUMN: + return QtCore.Qt.Checked if is_skip else QtCore.Qt.Unchecked if column == self.NEXT_RUN_COLUMN: return QtCore.Qt.Checked if next_run else QtCore.Qt.Unchecked if role == QtCore.Qt.BackgroundRole: if column == self.BREAK_COLUMN and is_break: - return QtGui.QBrush(QtCore.Qt.red) + return colors.BREAK_COLOR + if column == self.SKIP_COLUMN and is_skip: + return colors.SKIP_COLOR if column == self.START_COLUMN and is_start: - return QtGui.QBrush(QtCore.Qt.green) + return colors.START_COLOR if column == self.PATH_COLUMN and next_run: return QtGui.QBrush(QtCore.Qt.red) if role == QtCore.Qt.ForegroundRole: @@ -448,11 +466,18 @@ def data(self, index, role=None): def setData(self, index, value, role=QtCore.Qt.EditRole): column = index.column() - if column != self.BREAK_COLUMN: - return False if role != QtCore.Qt.CheckStateRole: return False path = self.nodes[index.row()] - current_break = self.stage_model.get_is_node_breakpoint(path) - self.stage_model.set_breakpoints([path], not current_break) - return True + if column == self.BREAK_COLUMN: + current_break = self.stage_model.get_is_node_breakpoint(path) + self.stage_model.set_breakpoints([path], not current_break) + return True + if column == self.SKIP_COLUMN: + modifiers = QtWidgets.QApplication.keyboardModifiers() + if modifiers == QtCore.Qt.ShiftModifier: + self.stage_model.toggle_descendant_skips([path]) + else: + self.stage_model.toggle_skippoints([path]) + return True + return False diff --git a/nxt_editor/dockwidgets/output_log.py b/nxt_editor/dockwidgets/output_log.py index 777b562..b5ec502 100644 --- a/nxt_editor/dockwidgets/output_log.py +++ b/nxt_editor/dockwidgets/output_log.py @@ -334,7 +334,9 @@ def write_raw_output(self, val, msg_time=0.): curr_rt_layer.cache_layer.was_during_node_exec(msg_time)): return if self.log_filter_button.is_level_enabled(nxt_log.NODEOUT): - self.write_rich_output(val) + # Intentionally breaking html syntax here. Printing type(object) + # was being interpreted as html. + self.write_rich_output(val.replace('<', '<')) def _write_raw_output(self, val): """Write text to the raw output textedit. Dose NOT write to the rich diff --git a/nxt_editor/integration/__init__.py b/nxt_editor/integration/__init__.py index 5b768b2..017412d 100644 --- a/nxt_editor/integration/__init__.py +++ b/nxt_editor/integration/__init__.py @@ -119,7 +119,7 @@ def uninstall(self): self._uninstall_package('nxt-core') if self.check_for_nxt_editor(): self._uninstall_package('nxt-editor') - # print('Please restart your DCC or Python interpreter') + print('Please restart your DCC or Python interpreter') def launch_nxt(self): raise NotImplementedError('Your DCC needs it own nxt launch method.') diff --git a/nxt_editor/integration/blender/README.md b/nxt_editor/integration/blender/README.md index d9215be..ce362ef 100644 --- a/nxt_editor/integration/blender/README.md +++ b/nxt_editor/integration/blender/README.md @@ -1,11 +1,17 @@ # Installation -**This is an experimental version of nxt_blender. Save early, save often.** +**This is an experimental version of nxt_blender. Save early, save often.** This is a Blender addon for nxt. Note that it will access the internet to install. +Please read all the steps below before starting. + +_In some of our testing we found that we needed to install Python on +the system inorder for Blender to be able to open the NXT editor. If you +get strange import errors when you try to import `nxt_editor`, try +installing Python (same version as Blender's) on your machine._ ### By hand (if you're familiar with pip) 1. Locate the path to blenders Python interpreter - In Blender, you can run `sys.exec_prefix` to find the folder containing the Python executable -2. Open Terminal or CMD (If you're using Windows) +2. Open Terminal, CMD, ect. - Must have elevated permissions 3. Run: `/path/to/blender/python.exe -m pip install nxt-editor` 4. Start Blender 5. Open the addon manager (Edit > Preferences > Add-ons) @@ -14,19 +20,21 @@ This is a Blender addon for nxt. Note that it will access the internet to instal ### Automated -1. Open the addon manager (Edit > Preferences > Add-ons) -2. Click "Install" and select the `nxt_blender.py` file provided with this addon zip -3. Enable the `NXT Blender` and twirl down the addon preferences -3. Click `Install NXT dependencies` - - The installation may take a minute or two, during this time Blender will be unresponsive - - Optionally open the console window before running the script, so you can see what's happening -4. Restart Blender +1. Launch Blender with elevated permissions +2. Open the addon manager (Edit > Preferences > Add-ons) +3. Click "Install" and select the `nxt_blender.py` file provided with this addon zip +4. Enable the `NXT Blender` and twirl down the addon preferences +5. Click `Install NXT dependencies` + - It is recommended to open the console window before running the script, so you can see what's happening. Window > Toggle System Console. + - The installation may take a minute or two, during this time Blender will be unresponsive. +6. Restart Blender # Usage - Ensure the `NXT Blender` addon is enabled - To launch NXT navigate the newly created `NXT` menu and select `Open Editor` # Updating +_These steps also require elevated permissions for Blender or the terminal._ ### By hand (if you're familiar with pip) 1. In terminal or cmd run: `/path/to/blender/python.exe -m pip install -U nxt-editor nxt` 2. Restart Blender @@ -44,6 +52,7 @@ _or_ 3. Restart Blender # Uninstall +_These steps also require elevated permissions for Blender or the terminal._ ### By hand (if you're familiar with pip) 1. Open the addon manager (Edit > Preferences > Add-ons) 2. Locate the `NXT Blender` and twirl down the addon preferences diff --git a/nxt_editor/integration/blender/__init__.py b/nxt_editor/integration/blender/__init__.py index 09f83bb..9776fe8 100644 --- a/nxt_editor/integration/blender/__init__.py +++ b/nxt_editor/integration/blender/__init__.py @@ -1,8 +1,9 @@ # Builtin +import atexit import os import shutil +import subprocess import sys -import atexit # External import bpy @@ -14,20 +15,33 @@ import nxt_editor __NXT_INTEGRATION__ = None +b_major, b_minor, b_patch = bpy.app.version class Blender(NxtIntegration): def __init__(self): super(Blender, self).__init__(name='blender') b_major, b_minor, b_patch = bpy.app.version - if b_major != 2 or b_minor < 80: + if b_major == 2 and b_minor < 80: raise RuntimeError('Blender version is not compatible with this ' 'version of nxt.') - addons_dir = bpy.utils.user_resource('SCRIPTS', 'addons') + if b_major == 2: + addons_dir = bpy.utils.user_resource('SCRIPTS', 'addons') + else: + addons_dir = os.path.join(bpy.utils.user_resource('SCRIPTS'), + '/addons') self.addons_dir = addons_dir.replace(os.sep, '/') self.instance = None self.nxt_qapp = QtWidgets.QApplication.instance() + @staticmethod + def show_message(message, title, icon='INFO'): + + def draw(self, *args): + self.layout.label(text=message) + + bpy.context.window_manager.popup_menu(draw, title=title, icon=icon) + @classmethod def setup(cls): self = cls() @@ -36,6 +50,58 @@ def setup(cls): shutil.copy(integration_filepath, self.addons_dir) bpy.ops.preferences.addon_enable(module='nxt_' + self.name) + def _install_and_import_package(self, module_name, package_name=None, + global_name=None): + """Calls a subprocess to pip install the given package name and then + attempts to import the new package. + + :param module_name: Desired module to import after install + :param package_name: pip package name + :param global_name: Global name to access the module if different + than the module name. + :raises: subprocess.CalledProcessError + :return: bool + """ + if package_name is None: + package_name = module_name + if global_name is None: + global_name = module_name + environ_copy = dict(os.environ) + environ_copy["PYTHONNOUSERSITE"] = "1" + pkg = 'nxt-editor' + if b_major == 2: + exe = bpy.app.binary_path_python + else: + exe = sys.executable + print('INSTLALING: ' + pkg) + subprocess.run([exe, "-m", "pip", "install", pkg], + check=True, env=environ_copy) + success = self._safe_import_package(package_name=package_name, + global_name=global_name) + Blender.show_message('NXT package Installed! ' + 'You may need to restart Blender.', + 'Success!') + return success + + @staticmethod + def _update_package(package_name): + """Calls a subprocess to pip update the given package name. + + :param package_name: pip package name + :raises: subprocess.CalledProcessError + :return: None + """ + environ_copy = dict(os.environ) + environ_copy["PYTHONNOUSERSITE"] = "1" + if b_major == 2: + exe = bpy.app.binary_path_python + else: + exe = sys.executable + subprocess.run([exe, "-m", "pip", "install", "-U", + package_name], check=True, env=environ_copy) + Blender.show_message('NXT package updated! ' + 'Please restart Blender.', 'Success!') + @classmethod def update(cls): self = cls() @@ -47,13 +113,13 @@ def update(cls): @classmethod def launch_nxt(cls): - self = cls() - os.environ[NXT_DCC_ENV_VAR] = 'blender' global __NXT_INTEGRATION__ - if not __NXT_INTEGRATION__: - __NXT_INTEGRATION__ = self - else: + if __NXT_INTEGRATION__: self = __NXT_INTEGRATION__ + else: + self = cls() + __NXT_INTEGRATION__ = self + os.environ[NXT_DCC_ENV_VAR] = 'blender' if self.instance: self.instance.show() return @@ -68,20 +134,19 @@ def launch_nxt(cls): def unregister_nxt(): self.instance = None - if self.nxt_qapp: - self.nxt_qapp.quit() - self.nxt_qapp = None nxt_win.close_signal.connect(unregister_nxt) nxt_win.show() + # Forces keyboard focus + nxt_win.activateWindow() atexit.register(nxt_win.close) self.instance = nxt_win return self def quit_nxt(self): if self.instance: - self.instance.close() atexit.unregister(self.instance.close) + self.instance.close() if self.nxt_qapp: self.nxt_qapp.quit() global __NXT_INTEGRATION__ @@ -94,3 +159,32 @@ def create_context(self): interpreter_exe=bpy.app.binary_path, exe_script_args=args) + def check_for_nxt_core(self, install=False): + success = super(Blender, self).check_for_nxt_core(install=install) + if not success: + Blender.show_message('Failed to import and/or install ' + 'nxt-editor.', 'Failed!') + return success + + @staticmethod + def _uninstall_package(package_name): + """Calls a subprocess to pip uninstall the given package name. Will + NOT prompt the user to confrim uninstall. + + :param package_name: pip package name + :raises: subprocess.CalledProcessError + :return: None + """ + environ_copy = dict(os.environ) + environ_copy["PYTHONNOUSERSITE"] = "1" + if b_major == 2: + exe = bpy.app.binary_path_python + else: + exe = sys.executable + subprocess.run([exe, "-m", "pip", "uninstall", + package_name, '-y'], check=True, env=environ_copy) + + def uninstall(self): + super(Blender, self).uninstall() + Blender.show_message('NXT was uninstalled, sorry ' + 'to see you go.', 'Uninstalled!') diff --git a/nxt_editor/integration/blender/nxt_blender.py b/nxt_editor/integration/blender/nxt_blender.py index f99857c..d6cf84c 100644 --- a/nxt_editor/integration/blender/nxt_blender.py +++ b/nxt_editor/integration/blender/nxt_blender.py @@ -25,8 +25,8 @@ bl_info = { "name": "NXT Blender", - "blender": (2, 80, 0), - "version": (0, 2, 0), + "blender": (3, 0, 0), + "version": (0, 3, 0), "location": "NXT > Open Editor", "wiki_url": "https://nxt-dev.github.io/", "tracker_url": "https://github.com/nxt-dev/nxt_editor/issues", @@ -38,6 +38,12 @@ "warning": "This addon requires installation of dependencies." } +b_major, b_minor, b_patch = bpy.app.version +if b_major == 2: + bl_info["blender"] = (2, 80, 0) +elif b_major != 3: + raise RuntimeError('Unsupported major Blender version: {}'.format(b_major)) + class BLENDER_PLUGIN_VERSION(object): plugin_v_data = {'MAJOR': bl_info["version"][0], @@ -111,7 +117,7 @@ def menu_draw(self, context): class NxtInstallDependencies(bpy.types.Operator): bl_idname = 'nxt.nxt_install_dependencies' - bl_label = "Install NXT dependencies" + bl_label = "Install NXT dependencies (Blender requires elevated permissions)" bl_description = ("Downloads and installs the required python packages " "for NXT. Internet connection is required. " "Blender may have to be started with elevated " @@ -126,19 +132,22 @@ def poll(cls, context): return not nxt_installed def execute(self, context): - success = False environ_copy = dict(os.environ) environ_copy["PYTHONNOUSERSITE"] = "1" pkg = 'nxt-editor' + if b_major == 2: + exe = bpy.app.binary_path_python + else: + exe = sys.executable try: - subprocess.run([sys.executable, "-m", "pip", "install", pkg], + subprocess.run([exe, "-m", "pip", "install", pkg], check=True, env=environ_copy) except subprocess.CalledProcessError as e: self.report({"ERROR"}, str(e)) return {"CANCELLED"} - if not success: - self.report({"INFO"}, 'Please restart Blender to ' - 'finish installing NXT.') + msg = 'Please restart Blender to finish installing NXT.' + self.report({"INFO"}, msg) + show_message(msg, "Installed dependencies!") return {"FINISHED"} @@ -227,6 +236,14 @@ def draw(self, context): icon="ERROR") +def show_message(message, title, icon='INFO'): + + def draw(self, *args): + self.layout.label(text=message) + + bpy.context.window_manager.popup_menu(draw, title=title, icon=icon) + + nxt_operators = (TOPBAR_MT_nxt, OpenNxtEditor, NxtUpdateDependencies, NxtUninstallDependencies, NxtDependenciesPreferences, NxtInstallDependencies, CreateBlenderContext) diff --git a/nxt_editor/main_window.py b/nxt_editor/main_window.py index 3dbb666..452d188 100644 --- a/nxt_editor/main_window.py +++ b/nxt_editor/main_window.py @@ -476,7 +476,6 @@ def new_tab(self, initial_stage=None, update=True): model.target_layer_changed.connect(self.update_target_color) model.comp_layer_changed.connect(self.update_target_color) self.update_target_color() - self.update_grid_action() self.update() # TODO: Make this better self.set_waiting_cursor(False) @@ -532,16 +531,6 @@ def save_open_tab(self): """Save the file that corresponds to the currently selected tab.""" self.nxt.save_file(self.get_current_tab_file_path()) - def save_open_tab_as(self): - raise NotImplementedError - """Open a QtWidgets.QFileDialog to allow user to select where to save the open tab. Then save.""" - # Todo: start us in last directory - not C: - save_path = QtWidgets.QFileDialog.getSaveFileName(filter="nxt files (*.json *.nxt)", dir="C:")[0] - current_tab_path = self.get_current_tab_file_path() - self.nxt.save_file(current_tab_path, save_path) - new_name = self.open_files[self.open_files_tab_widget.currentIndex()]['stage']._name - self.open_files_tab_widget.setTabText(self.open_files_tab_widget.currentIndex(), new_name) - def save_all_layers(self): if not self.model: return @@ -554,22 +543,35 @@ def save_layer(self, layer=None): if not layer: return if not layer.real_path: - self.save_layer_as(layer, open_in_new_tab=False) + layer_saved = self.save_layer_as(layer, open_in_new_tab=False) else: self.set_waiting_cursor(True) self.nxt.save_layer(layer) + layer_saved = True user_dir.editor_cache[user_dir.USER_PREF.LAST_OPEN] = layer.real_path self.view.update_filepath() - try: - self.model.effected_layers.remove(layer.real_path) - except KeyError: # Layer may not have been changed - pass + if layer_saved: + try: + self.model.effected_layers.remove(layer.real_path) + except KeyError: # Layer may not have been changed + pass self.model.layer_saved.emit(layer.real_path) self.set_waiting_cursor(False) def save_layer_as(self, layer=None, open_in_new_tab=True): + """Prompt the user to select a save location for a layer. + + If no layer is specified the target layer is used. + + :param layer: path to prompt for, defaults to target layer. + :type layer: nxt_layer.SpecLayer, optional + :param open_in_new_tab: If True, open the layer in a new tab after save + :type start: str, optional + :return: True if save was successful, false otherwise. + :rtype: bool + """ if not layer: - layer = self.model.display_layer + layer = self.model.target_layer old_real_path = layer.real_path old_path = layer.filepath if not old_real_path: @@ -581,7 +583,7 @@ def save_layer_as(self, layer=None, open_in_new_tab=True): save_path = NxtFileDialog.system_file_dialog(base_dir, 'save', caption=caption) if not save_path: - return + return False self.set_waiting_cursor(True) self.nxt.save_layer(layer, filepath=save_path) user_dir.editor_cache[user_dir.USER_PREF.LAST_OPEN] = layer.real_path @@ -595,6 +597,7 @@ def save_layer_as(self, layer=None, open_in_new_tab=True): tab_idx = self.open_files_tab_widget.currentIndex() self.on_tab_change(tab_idx) self.set_waiting_cursor(False) + return True def open_source(self, layer): if not layer: @@ -708,7 +711,9 @@ def on_tab_change(self, tab_index): logger.debug("Successfully set up new tab.") self.last_focused_tab = tab_index self.update_implicit_action() - self.update_grid_action() + view.toggle_grid( + user_dir.user_prefs.get(user_dir.USER_PREF.SHOW_GRID, True) + ) model.destroy_cmd_port.connect(self.update_cmd_port_action) else: logger.critical("Failed to set up new tab.") @@ -747,11 +752,6 @@ def update_cmd_port_action(self): self.execute_actions.enable_cmd_port_action.setChecked(state) self.execute_actions.enable_cmd_port_action.blockSignals(False) - def update_grid_action(self): - self.view_actions.grid_action.blockSignals(True) - self.view_actions.grid_action.setChecked(self.model.show_grid) - self.view_actions.grid_action.blockSignals(False) - def update_implicit_action(self): self.view_actions.implicit_action.blockSignals(True) state = self.model.implicit_connections @@ -1227,7 +1227,7 @@ def window_action_triggered(self, action=None): return widget = action.data() tab_index = self.main_window.open_files_tab_widget.indexOf(widget) - if tab_index is not -1: + if tab_index != -1: self.main_window.open_files_tab_widget.setCurrentIndex(tab_index) return if action.isChecked(): diff --git a/nxt_editor/node_graphics_item.py b/nxt_editor/node_graphics_item.py index cd92d78..707a75c 100644 --- a/nxt_editor/node_graphics_item.py +++ b/nxt_editor/node_graphics_item.py @@ -91,13 +91,16 @@ def __init__(self, model, node_path, view): self.user_attr_names = [] self._attribute_draw_details = OrderedDict() self._attr_plug_graphics = {} - self.exec_out_plug = None - self.exec_in_plug = None + self.exec_out_plug = NodeExecutionPlug(self.model, self.node_path, is_input=False, parent=self) + self.exec_in_plug = NodeExecutionPlug(self.model, self.node_path, is_input=True, parent=self) self.is_hovered = False self.collapse_state = False self.collapse_arrows = [] self.node_enabled = None self.node_instance = None + + self.model.build_idx_changed.connect(self.update_build_focus) + self.model.executing_changed.connect(self.update_build_focus) # draw node self.update_from_model() @@ -389,59 +392,9 @@ def draw_title(self, painter, lod=1.): # draw exec plugs exec_attr = nxt_node.INTERNAL_ATTRS.EXECUTE_IN exec_in_pos = self.get_attr_in_pos(exec_attr, scene=False) - exec_radius = self.EXEC_PLUG_RADIUS - if nxt_path.get_parent_path(self.node_path) == nxt_path.WORLD: - if self.is_start: - color = self.start_color - elif self.is_break: - color = QtCore.Qt.red - else: - color = QtCore.Qt.white - if not self.exec_in_plug: - is_break = self.is_break - if self.is_start: - is_break = False - self.exec_in_plug = NodeGraphicsPlug(pos=exec_in_pos, - radius=exec_radius, - color=color, - is_exec=True, - is_input=True, - is_break=is_break, - is_start=self.is_start) - self.exec_in_plug.setParentItem(self) - else: - self.exec_in_plug.color = QtGui.QColor(color) - self.exec_in_plug.is_break = self.is_break - self.exec_in_plug.is_start = self.is_start - self.exec_in_plug.setPos(exec_in_pos) - self.exec_in_plug.update() - - out_pos = self.get_attr_out_pos(exec_attr, scene=False) - if not self.exec_out_plug: - self.exec_out_plug = NodeGraphicsPlug(pos=out_pos, - radius=exec_radius, - color=QtCore.Qt.white, - is_exec=True, - is_input=False) - self.exec_out_plug.setParentItem(self) - else: - self.exec_out_plug.setPos(out_pos) - else: - if not self.exec_in_plug and self.is_break: - self.exec_in_plug = NodeGraphicsPlug(pos=exec_in_pos, - radius=exec_radius, - color=QtCore.Qt.red, - is_exec=True, - is_input=True, - is_break=self.is_break) - self.exec_in_plug.setParentItem(self) - elif self.exec_in_plug and not self.is_break: - self.scene().removeItem(self.exec_in_plug) - self.exec_in_plug = None - - if self.exec_out_plug: - self.scene().removeItem(self.exec_out_plug) - self.exec_out_plug = None + out_pos = self.get_attr_out_pos(exec_attr, scene=False) + self.exec_out_plug.setPos(out_pos) + self.exec_in_plug.setPos(exec_in_pos) if lod > MIN_LOD: # draw attr dots offset = -6 @@ -545,17 +498,16 @@ def draw_attributes(self, painter, lod=1.): in_pos = self.get_attr_in_pos(attr_name, scene=False) if current_in_plug: current_in_plug.show() - current_in_plug.setPos(in_pos) current_in_plug.color = target_color current_in_plug.update() else: - current_in_plug = NodeGraphicsPlug(pos=in_pos, - radius=self.ATTR_PLUG_RADIUS, + current_in_plug = NodeGraphicsPlug(radius=self.ATTR_PLUG_RADIUS, color=target_color, attr_name_represented=attr_name, - is_input=True) + is_input=True, + parent=self) attr_plug_graphics['in_plug'] = current_in_plug - current_in_plug.setParentItem(self) + current_in_plug.setPos(in_pos) elif current_in_plug: current_in_plug.hide() @@ -564,17 +516,16 @@ def draw_attributes(self, painter, lod=1.): out_pos = self.get_attr_out_pos(attr_name, scene=False) if current_out_plug: current_out_plug.show() - current_out_plug.setPos(out_pos) current_out_plug.color = target_color current_out_plug.update() else: - out_plug = NodeGraphicsPlug(pos=out_pos, - radius=self.ATTR_PLUG_RADIUS, - color=target_color, - attr_name_represented=attr_name, - is_input=False) - attr_plug_graphics['out_plug'] = out_plug - out_plug.setParentItem(self) + current_out_plug = NodeGraphicsPlug(radius=self.ATTR_PLUG_RADIUS, + color=target_color, + attr_name_represented=attr_name, + is_input=False, + parent=self) + attr_plug_graphics['out_plug'] = current_out_plug + current_out_plug.setPos(out_pos) elif current_out_plug: current_out_plug.hide() @@ -646,10 +597,10 @@ def calculate_attribute_draw_details(self): # Internal Attrs # Exec draw_details = {} - in_pos = QtCore.QPointF(0, self.title_bounding_rect.height()/3) + in_pos = QtCore.QPointF(0, self.title_bounding_rect.height()*0.5) draw_details['in_pos'] = in_pos out_pos = QtCore.QPointF(self.max_width, - self.title_bounding_rect.height()/3) + self.title_bounding_rect.height()*0.5) draw_details['out_pos'] = out_pos draw_details['plug_color'] = QtGui.QColor(QtCore.Qt.white) exec_attr = nxt_node.INTERNAL_ATTRS.EXECUTE_IN @@ -700,13 +651,11 @@ def hoverLeaveEvent(self, event): super(NodeGraphicsItem, self).hoverLeaveEvent(event) def update_build_focus(self): - if not self.model.can_build_run(): + if not self.model.executing: self.is_build_focus = False self.update() return - idx = self.model.last_built_idx + 1 - build_focus = self.model.current_build_order[idx] - self.is_build_focus = build_focus == self.node_path + self.is_build_focus = self.node_path == self.model.get_build_focus() self.update() def update_from_model(self): @@ -948,14 +897,12 @@ def get_attr_out_pos(self, attr_name, scene=True): class NodeGraphicsPlug(QtWidgets.QGraphicsItem): - """Graphics item for attribute and execution plugs on the NodeGraphicsItem.""" + """Graphics item for user attribute plugs on the NodeGraphicsItem.""" - def __init__(self, pos=QtCore.QPointF(), radius=3, hover_width=0.5, color=QtGui.QColor(255, 255, 255, 255), - attr_name_represented='', is_exec=False, is_input=False, - is_break=False, is_start=False, start_idx=-1): - super(NodeGraphicsPlug, self).__init__() + def __init__(self, radius=3, hover_width=0.5, color=QtGui.QColor(255, 255, 255, 255), + attr_name_represented='', is_input=False, parent=None): + super(NodeGraphicsPlug, self).__init__(parent=parent) self.setAcceptHoverEvents(True) - self.setPos(pos) # TODO benchmark this cache setting to see if it helps or hurts performance self.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache) @@ -963,12 +910,7 @@ def __init__(self, pos=QtCore.QPointF(), radius=3, hover_width=0.5, color=QtGui. self.hover_width = hover_width self.color = QtGui.QColor(color) self.attr_name_represented = attr_name_represented - self.is_exec = is_exec - if is_exec: - self.attr_name_represented = nxt_node.INTERNAL_ATTRS.EXECUTE_IN self.is_input = is_input - self.is_break = is_break - self.is_start = is_start self.is_hovered = False @@ -982,8 +924,7 @@ def boundingRect(self): (self.radius + offset) * 2, (self.radius + offset) * 2) - def paint(self, painter, option, widget): - """Override of QtWidgets.QGraphicsItem paint. Handles all visuals of the Plug.""" + def _apply_lod_to_painter(self, painter): lod = QtWidgets.QStyleOptionGraphicsItem.levelOfDetailFromTransform( painter.worldTransform()) if lod > MIN_LOD: @@ -994,38 +935,16 @@ def paint(self, painter, option, widget): painter.setRenderHints(QtGui.QPainter.Antialiasing | QtGui.QPainter.TextAntialiasing | QtGui.QPainter.SmoothPixmapTransform, False) + + def paint(self, painter, option, widget): + """Override of QtWidgets.QGraphicsItem paint. Handles all visuals of the Plug.""" + self._apply_lod_to_painter(painter) if self.is_hovered: painter.setPen(QtGui.QPen(QtCore.Qt.white, self.hover_width, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin)) else: painter.setPen(QtCore.Qt.NoPen) painter.setBrush(self.color) - - if self.is_start: - self.radius = 18 - # create triangle - polygon = QtGui.QPolygonF() - step_angle = 120 - for i in [0, 1, 2, 3]: - step = step_angle * i - x = self.radius * 1.2 * math.cos(math.radians(step)) - y = self.radius * 1.2 * math.sin(math.radians(step)) - polygon.append(QtCore.QPointF(x, y)) - - # center - rect = polygon.boundingRect() - width = self.radius * 2.4 - offset = rect.width() - width - polygon.translate(offset, 0) - # draw - pen = QtGui.QPen(self.color, self.hover_width * 8) - painter.setPen(pen) - painter.setBrush(QtCore.Qt.green) - painter.drawPolygon(polygon) - - elif self.is_break: - painter.drawRect(self.radius * -1, self.radius * -1, self.radius * 2, self.radius * 2) - else: - painter.drawEllipse(QtCore.QPointF(0, 0), self.radius, self.radius) + painter.drawEllipse(QtCore.QPointF(0, 0), self.radius, self.radius) def itemChange(self, change, value): """Override of QtWidgets.QGraphicsItem itemChange.""" @@ -1082,6 +1001,97 @@ def mouseReleaseEvent(self, event): super(NodeGraphicsPlug, self).mouseReleaseEvent(event) +class NodeExecutionPlug(NodeGraphicsPlug): + """Node Graphics Plug for the plugs in the execution position. + + Handles drawing of exec plugs, as well as start, break, and skip points. + """ + def __init__(self, model, node_path, is_input, parent=None): + super(NodeExecutionPlug, self).__init__( + radius=NodeGraphicsItem.EXEC_PLUG_RADIUS, + color=QtCore.Qt.white, + attr_name_represented=nxt_node.INTERNAL_ATTRS.EXECUTE_IN, + is_input=is_input, + parent=parent + ) + self.node_path = node_path + self.model = model + + self.is_root = None + self.is_break = None + self.is_start = None + self.start_color = None + self.is_skip = None + self.model.starts_changed.connect(self._refresh_is_start) + self.model.breaks_changed.connect(self._refresh_is_break) + self.model.skips_changed.connect(self._refresh_is_skip) + self._refresh_from_model() + + def _refresh_is_start(self): + self.is_start = self.model.get_is_node_start(self.node_path, self.model.comp_layer) + if self.is_start: + self.start_color = self.model.get_node_attr_color( + self.node_path, INTERNAL_ATTRS.START_POINT, self.model.comp_layer) + self.update() + + def _refresh_is_break(self): + self.is_break = self.model.get_is_node_breakpoint(self.node_path, self.model.comp_layer) + self.update() + + def _refresh_is_skip(self): + self.is_skip = self.model.is_node_skippoint(self.node_path, self.model.comp_layer.real_path) + self.update() + + def _refresh_from_model(self): + self.is_root = nxt_path.get_path_depth(self.node_path) == 1 + self._refresh_is_break() + self._refresh_is_start() + self._refresh_is_skip() + + def paint(self, painter, option, widget): + """Override of QtWidgets.QGraphicsItem paint. Handles all visuals of the Plug.""" + special_input = any([self.is_break, self.is_start, self.is_skip]) + # If an output, or an non-special input. + if (not self.is_input) or (not special_input): + if self.is_root: + self.radius = NodeGraphicsItem.EXEC_PLUG_RADIUS + super(NodeExecutionPlug, self).paint(painter, option, widget) + # For non-root non-specials, no drawing. + return + self._apply_lod_to_painter(painter) + shape_border_pen = QtGui.QPen(colors.GRAPH_BG_COLOR, self.hover_width * 2) + if self.is_start: # A green triangle, with a border of layer color + self.radius = 13 + painter.setBrush(QtGui.QColor(self.start_color)) + painter.setPen(shape_border_pen) + painter.drawPolygon(self._buildTriangle(QtCore.QPointF(self.radius * -0.6, 0), self.radius)) + painter.setBrush(colors.START_COLOR) + painter.setPen(QtCore.Qt.NoPen) + painter.drawPolygon(self._buildTriangle(QtCore.QPointF(self.radius * -0.6, 0), self.radius * .7)) + elif self.is_skip: # A short wide rect, a minus + self.radius = 9 + painter.setBrush(colors.SKIP_COLOR) + painter.setPen(shape_border_pen) + skip_rect = QtCore.QRect(self.radius * -1, self.radius * -.6, self.radius * 2, self.radius) + painter.drawRoundedRect(skip_rect, 4, 4) + elif self.is_break: # A red square + self.radius = 8 + painter.setBrush(colors.BREAK_COLOR) + painter.setPen(shape_border_pen) + painter.drawRect(self.radius * -1, self.radius * -1, self.radius * 2, self.radius * 2) + + def _buildTriangle(self, offset, side_length): + polygon = QtGui.QPolygonF() + step_angle = 120 + for i in [0, 1, 2, 3]: + step = step_angle * i + x = side_length * 1.2 * math.cos(math.radians(step)) + y = side_length * 1.2 * math.sin(math.radians(step)) + polygon.append(QtCore.QPointF(x, y)) + polygon.translate(offset) + return polygon + + class CollapseArrow(QtWidgets.QGraphicsItem): """Graphics item for when NodeGraphicsItem stacks are collapsed.""" diff --git a/nxt_editor/stage_model.py b/nxt_editor/stage_model.py index 0b27cc4..f7aec00 100644 --- a/nxt_editor/stage_model.py +++ b/nxt_editor/stage_model.py @@ -19,7 +19,7 @@ from nxt import (nxt_path, nxt_layer, tokens, DATA_STATE, NODE_ERRORS, GRID_SIZE) import nxt_editor -from nxt_editor import DIRECTIONS, StringSignaler +from nxt_editor import DIRECTIONS, StringSignaler, user_dir from nxt.nxt_layer import LAYERS, CompLayer, SAVE_KEY from nxt.nxt_node import (get_node_attr, META_ATTRS, get_node_as_dict, get_node_enabled) @@ -50,7 +50,6 @@ class StageModel(QtCore.QObject): build_paused_changed = QtCore.Signal(bool) processing = QtCore.Signal(bool) data_state_changed = QtCore.Signal(bool) - show_grid_changed = QtCore.Signal(bool) implicit_connections_changed = QtCore.Signal(bool) layer_color_changed = QtCore.Signal(object) comp_layer_changed = QtCore.Signal(object) @@ -73,7 +72,8 @@ class StageModel(QtCore.QObject): node_name_changed = QtCore.Signal(str, str) # old node path, new node path node_parent_changed = QtCore.Signal(str, str) # old node path, new node path starts_changed = QtCore.Signal(tuple) # new start point paths - breaks_changed = QtCore.Signal(tuple) # new break point paths + breaks_changed = QtCore.Signal(tuple) # new break point paths + skips_changed = QtCore.Signal(tuple) # new skip point paths collapse_changed = QtCore.Signal(tuple) # node paths where changed frame_items = QtCore.Signal(tuple) server_log = QtCore.Signal(str) @@ -103,7 +103,6 @@ def __init__(self, stage): self.refresh_exec_framing_from_pref() # model states self._data_state = DATA_STATE.RESOLVED - self._show_grid = True self._implicit_connections = True # graph layers self._comp_layer = stage.build_stage() @@ -443,15 +442,6 @@ def data_state(self, value): self._data_state = value self.data_state_changed.emit(value) - @property - def show_grid(self): - return self._show_grid - - @show_grid.setter - def show_grid(self, value): - self._show_grid = value - self.show_grid_changed.emit(value) - @property def implicit_connections(self): return self._implicit_connections @@ -2103,7 +2093,12 @@ def set_breakpoints(self, node_paths=(), value=None, layer=None): node_paths = self.selection if not node_paths: return - layer_path = self.get_layer_path(layer, fallback=LAYERS.TARGET) + layer_path = self.get_layer_path(layer, + fallback=self.top_layer.real_path) + if not layer_path: + logger.warning('Please save your graph to use breaks.') + self.request_ding.emit() + return on = [] off = [] for node_path in node_paths: @@ -2180,6 +2175,154 @@ def _remove_breakpoint(self, node_path, layer): user_dir.breakpoints[layer_path] = layer_breaks self.breaks_changed.emit(layer_breaks) + def toggle_skippoints(self, node_paths, layer_path=None): + """Reverse the skip status of all given node paths. + + :param node_paths: Node paths to toggle. + :type node_paths: iterable + :param layer_path: Layer to set skips on, defaults to top layer. + :type layer_path: str, optional + """ + if not layer_path: + layer_path = self.top_layer.real_path + if not layer_path: + logger.warning('Please save your graph to use skips.') + self.request_ding.emit() + return + on = [] + off = [] + for node_path in node_paths: + current_state = self.is_node_skippoint(node_path, layer_path) + node_val = not current_state + if node_val: + on += [node_path] + else: + off += [node_path] + node_count = len(node_paths) + if node_count > 1: + msg = ('Set skippoint for {} and ' + '{} other(s)'.format(node_paths[0], node_count - 1)) + else: + msg = 'Set skippoint for {}'.format(node_paths[0]) + self.undo_stack.beginMacro(msg) + if on: + self.set_skippoints(on, True, layer_path) + if off: + self.set_skippoints(off, False, layer_path) + self.undo_stack.endMacro() + + def toggle_descendant_skips(self, node_paths, layer_path=None): + """Reverse the skip point status of each node given, applying the same + status to all descendant nodes. + + :param node_paths: Root node(s) to change skip status of. + :type node_paths: iterable + :param layer_path: Layer to set skips on, defaults to top layer. + :type layer_path: str, optional + """ + if not layer_path: + layer_path = self.top_layer.real_path + if not layer_path: + logger.warning('Please save your graph to use skips.') + self.request_ding.emit() + return + node_count = len(node_paths) + if node_count > 1: + msg = ('Set skippoint for {} and ' + '{} other(s)'.format(node_paths[0], node_count - 1)) + else: + msg = 'Set skippoint for {}'.format(node_paths[0]) + self.undo_stack.beginMacro(msg) + for node_path in node_paths: + to_skip = not self.is_node_skippoint(node_path) + descendants = self.get_descendants(node_path) + self.set_skippoints(descendants + [node_path], to_skip, layer_path) + self.undo_stack.endMacro() + + def set_skippoints(self, node_paths, to_skip, layer_path=None): + """Set the skip status of given nodes. + + :param node_paths: Nodes to set skip status of. + :type node_paths: iterable + :param to_skip: Whether to set nodes to skip or not. + :type to_skip: bool + :param layer_path: Layer to set skips on, defaults to top layer. + :type layer_path: str, optional + """ + if not layer_path: + layer_path = self.top_layer.real_path + if not layer_path: + logger.warning('Please save your graph to use skips.') + self.request_ding.emit() + return + cmd = SetNodesAreSkipPoints(node_paths, to_skip, layer_path, self) + self.undo_stack.push(cmd) + + def is_node_skippoint(self, node_path, layer_path=None): + """Returns True/False based on whether a node is currently a skippoint. + + :param node_path: Node to check. + :type node_path: str + :param layer_path: Layer path to check within, defaults to top layer. + :type layer_path: str, optional + :return: Whether given node is a skip. + :rtype: bool + """ + if not layer_path: + layer_path = self.top_layer.real_path + layer_skips = user_dir.skippoints.get(layer_path, []) + return node_path in layer_skips + + def _add_skippoint(self, node_path, layer_path): + """Internal(not undo-able) method to make a node a skip point. + + :param node_path: Node to make a skip point. + :type node_path: str + :param layer_path: Layer to set skip for. + :type layer_path: str + :raises ValueError: If inputs are not complete. + """ + if not (node_path and layer_path): + raise ValueError("Must provide node and layer path.") + node_path = str(node_path) + layer_path = str(layer_path) + layer_skips = user_dir.skippoints.get(layer_path, []) + if node_path in layer_skips: + # no need to re-write existing data to pref + return + layer_skips.append(node_path) + user_dir.skippoints[layer_path] = layer_skips + if layer_path == self.top_layer.real_path: + self.skips_changed.emit([]) + + def _remove_skippoint(self, node_path, layer_path): + """Internal(not undo-able) method to make a node not a skip point. + + :param node_path: Node to remove as a skip point. + :type node_path: str + :param layer_path: Layer to set skip for. + :type layer_path: str + :raises ValueError: If inputs are not complete. + """ + if not (node_path and layer_path): + raise ValueError("Must provide node and layer path.") + node_path = str(node_path) + layer_path = str(layer_path) + layer_skips = user_dir.skippoints.get(layer_path, []) + if not layer_skips: + return + try: + layer_skips.remove(node_path) + except ValueError: + # Can return without the below save in this case. If the node + # path is not present, it's already "removed" + if layer_path == self.top_layer.real_path: + self.skips_changed.emit([]) + return + user_dir.skippoints[layer_path] = layer_skips + if layer_path == self.top_layer.real_path: + self.skips_changed.emit([]) + def get_is_node_start(self, node_path, layer=None): """Gets the start node state of a given node. :param node_path: String node path @@ -2306,6 +2449,9 @@ def node_is_implied(self, node_path, layer=None): layer = self.comp_layer if self.node_exists(node_path, layer=layer): return False + if node_path == nxt_path.WORLD: + # If we're in a graph, the world node is always at least implied. + return True parent_path = nxt_path.get_parent_path(node_path) children = self.get_children(parent_path, layer=layer, include_implied=True) @@ -2959,14 +3105,14 @@ def setup_build(self, node_paths, rt_layer=None): self._set_build_paused(True, focus=False) def get_build_focus(self): - """Build "focus" is the next-up node that will run when stepped. + """Build "focus" is the currently running node. - :return: next node path that will run when stepped/resumed. + :return: node path currently running, if running. :rtype: str """ if not self.can_build_run(): return '' - return self.current_build_order[self.last_built_idx + 1] + return self.current_build_order[self.last_built_idx] @property def last_built_idx(self): @@ -3032,6 +3178,7 @@ def resume_build(self): stop = len(self.current_build_order) breaks = user_dir.breakpoints.get(self.top_layer.real_path, [])[:] + skips = user_dir.skippoints.get(self.top_layer.real_path, [])[:] first_path = self.current_build_order[start] skip_pref_key = user_dir.USER_PREF.SKIP_INITIAL_BREAK skip_first_break = user_dir.user_prefs.get(skip_pref_key, True) @@ -3045,6 +3192,14 @@ def resume_build(self): self._set_build_paused(False) for i in range(start, stop): node_path = self.current_build_order[i] + # Skips before breaks, that's the current flow. + # This assumption is in 2 places, here and in + # NodeExecutionPlug, where it draws skips instead of breaks + if node_path in skips: + self.last_built_idx = i + skip_msg = "Skippoint triggers skip of {}".format(node_path) + logger.execinfo(skip_msg, links=[node_path]) + continue if node_path in breaks: break_msg = " !! Breakpoint hit at: {}".format(node_path) logger.execinfo(break_msg, links=[node_path]) diff --git a/nxt_editor/stage_view.py b/nxt_editor/stage_view.py index d362aa8..ddac0b9 100644 --- a/nxt_editor/stage_view.py +++ b/nxt_editor/stage_view.py @@ -17,6 +17,7 @@ from nxt_editor.connection_graphics_item import AttrConnectionGraphic from nxt_editor.dialogs import NxtWarningDialog from nxt_editor.commands import * +from nxt_editor import colors from .user_dir import USER_PREF, user_prefs logger = logging.getLogger(nxt_editor.LOGGER_NAME) @@ -121,7 +122,7 @@ def __init__(self, model, parent=None): self.prev_build_focus_path = None # local attributes - self.show_grid = True + self.show_grid = user_prefs.get(USER_PREF.SHOW_GRID, True) # connection attribute used when drawing connections self.potential_connection = None @@ -137,7 +138,6 @@ def __init__(self, model, parent=None): self.model.node_moved.connect(self.handle_node_move) self.model.selection_changed.connect(self.on_model_selection_changed) self.model.frame_items.connect(self.frame_nodes) - self.model.build_idx_changed.connect(self.on_build_idx_changed) self.model.collapse_changed.connect(self.handle_collapse_changed) # initialize the view @@ -298,7 +298,6 @@ def toggle_grid(self, state=None): self.show_grid = not self.show_grid else: self.show_grid = state - self.model.show_grid = self.show_grid self.update() def frame_all(self): @@ -596,8 +595,7 @@ def drawBackground(self, painter, rect): super(StageView, self).drawBackground(painter, rect) rect = self.sceneRect() - color = QtGui.QColor(35, 35, 35) - painter.fillRect(rect, QtGui.QBrush(color)) + painter.fillRect(rect, QtGui.QBrush(colors.GRAPH_BG_COLOR)) left = int(rect.left()) - (int(rect.left()) % self.draw_grid_size) top = int(rect.top()) - (int(rect.top()) % self.draw_grid_size) @@ -914,7 +912,7 @@ def mouseReleaseEvent(self, event): items_released_on = self.items(event.pos()) # using the 1 index item here because the 0 index thing will always be the connection if len(items_released_on) > 1: - if type(items_released_on[1]) is NodeGraphicsPlug: + if isinstance(items_released_on[1], NodeGraphicsPlug): dropped_plug = items_released_on[1] dropped_node_path = dropped_plug.parentItem().node_path locked = self.model.get_node_locked(dropped_node_path) @@ -1127,21 +1125,6 @@ def get_sel_path_for_graphic(graphic): # return graphic.tgt_path return None - def on_build_idx_changed(self, build_idx): - prev_graphic = self.get_node_graphic(self.prev_build_focus_path) - if prev_graphic is not None: - prev_graphic.update_build_focus() - focus_path = self.model.get_build_focus() - if not focus_path: - self.prev_build_focus_path = None - return - graphic = self.get_node_graphic(focus_path) - if not graphic: - self.prev_build_focus_path = None - return - self.prev_build_focus_path = focus_path - graphic.update_build_focus() - def on_model_selection_changed(self, new_selection): if not new_selection: self.scene().clearSelection() diff --git a/nxt_editor/user_dir.py b/nxt_editor/user_dir.py index 82ee6af..a227015 100644 --- a/nxt_editor/user_dir.py +++ b/nxt_editor/user_dir.py @@ -28,6 +28,7 @@ USER_PREFS_PATH = os.path.join(PREF_DIR, 'prefs.json') EDITOR_CACHE_PATH = os.path.join(PREF_DIR, 'editor_cache') BREAKPOINT_FILE = os.path.join(PREF_DIR, 'breakpoints') +SKIPPOINT_FILE = os.path.join(PREF_DIR, 'skippoints') HOTKEYS_PREF = os.path.join(PREF_DIR, 'hotkeys.json') MAX_RECENT_FILES = 10 @@ -74,6 +75,7 @@ class USER_PREF(): SHOW_DBL_CLICK_MSG = 'show_double_click_message' SHOW_CE_DATA_STATE = 'show_code_editor_data_state' DING = 'ding' + SHOW_GRID = 'show_grid' class EDITOR_CACHE(): @@ -319,6 +321,7 @@ def keys(self): user_prefs = JsonPref(USER_PREFS_PATH) hotkeys = JsonPref(HOTKEYS_PREF) breakpoints = JsonPref(BREAKPOINT_FILE) +skippoints = JsonPref(SKIPPOINT_FILE) editor_cache = PicklePref(EDITOR_CACHE_PATH) editor_cache.set_handler(USER_PREF.LAST_OPEN, LastOpenedHandler) # TODO as a session starts(or ends?), let's create a symlink to diff --git a/nxt_editor/version.json b/nxt_editor/version.json index ca3f947..07ed0e2 100644 --- a/nxt_editor/version.json +++ b/nxt_editor/version.json @@ -1,7 +1,7 @@ { "EDITOR": { "MAJOR": 3, - "MINOR": 11, - "PATCH": 1 + "MINOR": 12, + "PATCH": 0 } } diff --git a/nxt_env.yml b/nxt_env.yml index b4e8184..97d32f0 100644 --- a/nxt_env.yml +++ b/nxt_env.yml @@ -2,8 +2,8 @@ name: nxt_editor channels: - conda-forge dependencies: - - python=3.7 + - python=3.9 - qt.py=1.1 - - pyside2=5.11.1 + - pyside2=5.13.2 - twine - requests diff --git a/setup.py b/setup.py index ad937f1..e7ec1e2 100644 --- a/setup.py +++ b/setup.py @@ -28,10 +28,10 @@ long_description_content_type="text/markdown", url="https://github.com/nxt-dev/nxt_editor", packages=setuptools.find_packages(), - python_requires='>=2.7, <3.8', - install_requires=['nxt-core<1.0,>=0.13', + python_requires='>=2.7, <3.10', + install_requires=['nxt-core<1.0,>=0.14', 'qt.py==1.1', - 'pyside2==5.11.1' + 'pyside2>=5.11,<=5.16' ], package_data={ # covers text nxt files