From b8b6b9176a0113254e9dbca00c95c4b97617e557 Mon Sep 17 00:00:00 2001 From: Julian Slane Date: Mon, 11 Mar 2024 10:44:37 -0700 Subject: [PATCH 1/9] basic popup rename refactor --- constrain/app/README.md | 8 ++-- constrain/app/advanced_popup.py | 35 +++++++++------- .../app/{popup_window.py => basic_popup.py} | 4 +- constrain/app/rect_connect.py | 3 +- constrain/app/workflow_diagram.py | 42 ++++++++++++------- 5 files changed, 55 insertions(+), 37 deletions(-) rename constrain/app/{popup_window.py => basic_popup.py} (99%) diff --git a/constrain/app/README.md b/constrain/app/README.md index 8339f252..b7e62337 100644 --- a/constrain/app/README.md +++ b/constrain/app/README.md @@ -2,7 +2,7 @@ ## Background This tool builds workflows following the ConStrain API schema. The GUI provides the following: -- a graphical representation of their workflow +- a graphical representation of their workflow - a layer of abstraction over the process of creating a workflow - validation and submission of workflows @@ -30,7 +30,7 @@ The basic form is meant to guide the user in the creation of a state. It is trig The advanced form offers no guidance in the creation of a state. It is triggered by navigating to the 'State' tab and pressing the 'Add Advanced' button, and also triggered by clicking a state in the Workflow Diagram while using Advanced Settings. It is a text box where the user inputs a state. The state should be in the following format: ```json - { + { "name of state": { ... } @@ -89,7 +89,7 @@ The application contains 9 Python source files: - `submit` #### app -This file runs the GUI. It is responsible for piecing together the main components of the app, like the meta form, the import form, and the workflow diagram. It also handles validate, import, and export functionalities. It contains 2 classes: +This file runs the GUI. It is responsible for piecing together the main components of the app, like the meta form, the import form, and the workflow diagram. It also handles validate, import, and export functionalities. It contains 2 classes: - `GUI(QMainWindow)`: pieces classes together - `UserSetting(QDialog)`: dialog to configure basic or advanced setting @@ -108,7 +108,7 @@ This file is based on classes for organizing the UI of workflow visualization. I - `WorkflowDiagram(QWidget)`: node manager #### popup_window -This file contains the `PopupWindow(QDialog)` class. This class handles the Basic Popup, which is a form that describes a state. It is responsible for handling the creation of a state and the import of a state. +This file contains the `BasicPopup(QDialog)` class. This class handles the Basic Popup, which is a form that describes a state. It is responsible for handling the creation of a state and the import of a state. #### advanced_popup This file contains the `AdvancedPopup(QDialog)` class. This class handles the Advanced Popup, which is a text box that describes a state in JSON format. It is responsible for handling the creation of a state and the import of a state. The following is an example entry to this text box: diff --git a/constrain/app/advanced_popup.py b/constrain/app/advanced_popup.py index eb223741..0564f4de 100644 --- a/constrain/app/advanced_popup.py +++ b/constrain/app/advanced_popup.py @@ -9,6 +9,7 @@ QMessageBox, ) from PyQt6.QtGui import QFontMetricsF +from constrain.app import utils class AdvancedPopup(QDialog): @@ -97,20 +98,24 @@ def check_state(self): if state_type not in ["Choice", "MethodCall"]: self.error = True self.error_popup("Invalid type") - elif state_type == "Choice" and "Choices" not in state.keys(): - self.error = True - self.error_popup("Choice type, but no Choices key") + elif state_type == "Choice": + if "Choices" not in state: + self.error = True + utils.send_error( + "Error in State", "Choice type, but no Choices key" + ) + elif any(key in state for key in ("Start", "End")): + self.error = True + utils.send_error( + "Error in State", "Start/End keys not allowed in a Choice type" + ) + else: + self.close() else: + if all(key in state for key in ("Start", "End")): + self.error = True + utils.send_error( + "Error in State", + "Cannot have both Start and End keys in a state", + ) self.close() - - def error_popup(self, text): - """Executes an error popup with a given message. - - Args: - text (str): The message to be displayed - """ - error_msg = QMessageBox() - error_msg.setIcon(QMessageBox.Icon.Critical) - error_msg.setWindowTitle("Error in State") - error_msg.setText(text) - error_msg.exec() diff --git a/constrain/app/popup_window.py b/constrain/app/basic_popup.py similarity index 99% rename from constrain/app/popup_window.py rename to constrain/app/basic_popup.py index a776ee0e..5c89e44e 100644 --- a/constrain/app/popup_window.py +++ b/constrain/app/basic_popup.py @@ -1,5 +1,5 @@ """ -Contained is the class for PopupWindow, the popup displayed when 'Add Basic' is chosen in the states tab. +Contained is the class for BasicPopup, the popup displayed when 'Add Basic' is chosen in the states tab. """ import json @@ -36,7 +36,7 @@ api_to_method = json.load(f) -class PopupWindow(QDialog): +class BasicPopup(QDialog): def __init__(self, payloads=[], state_names=[], rect=None, load=False): """Form to be displayed for user to edit or add a basic state diff --git a/constrain/app/rect_connect.py b/constrain/app/rect_connect.py index 3cf65d59..b6d7c0af 100644 --- a/constrain/app/rect_connect.py +++ b/constrain/app/rect_connect.py @@ -3,6 +3,7 @@ CustomItem contains the state, Path links 2 ControlPoints with an arrowed line, ControlPoints are on the edges of CustomItems, and Scene contains all of this. """ + import json import math import re @@ -313,7 +314,7 @@ def __init__(self, state, popup=None): Args: state (dict): state that self represents - popup (PopupWindow or AdvancedPopup): popup associated with self + popup (BasicPopup or AdvancedPopup): popup associated with self """ super().__init__() # fill diff --git a/constrain/app/workflow_diagram.py b/constrain/app/workflow_diagram.py index 6e9f03b0..36b39fa4 100644 --- a/constrain/app/workflow_diagram.py +++ b/constrain/app/workflow_diagram.py @@ -17,9 +17,10 @@ QPen, QBrush, ) -from constrain.app.popup_window import PopupWindow +from constrain.app.basic_popup import BasicPopup from constrain.app.advanced_popup import AdvancedPopup from constrain.app.rect_connect import Scene, CustomItem, ControlPoint, Path +from constrain.app import utils import json @@ -193,6 +194,8 @@ def __init__(self, setting): # last popup accessed self.popup = None + self.root = None + # buttons add_buttons = QHBoxLayout() reformat_button_layout = QHBoxLayout() @@ -200,7 +203,7 @@ def __init__(self, setting): basic_button = QPushButton("Add Basic") basic_button.setToolTip("Create a state using the basic popup") basic_button.setFixedSize(100, 23) - basic_button.clicked.connect(self.call_popup) + basic_button.clicked.connect(self.call_basic_popup) add_buttons.addWidget(basic_button) advanced_button = QPushButton("Add Advanced") @@ -328,6 +331,7 @@ def edit_state(self, rect): def get_workflow(self): """Computes structure of the workflow using Depth First Search and paints CustomItems depending on place in graph""" items = [item for item in self.scene.items() if isinstance(item, CustomItem)] + roots = [] for i in items: parent = True @@ -338,7 +342,15 @@ def get_workflow(self): if parent: roots.append(i) - visited = set() + if len(roots) == 0: + return + elif len(roots) > 1: + utils.send_error("Error in Workflow", "More than 1 root node") + return + else: + root = roots[0] + + visited = set() paths = [] def dfs_helper(item, path): @@ -361,15 +373,15 @@ def dfs_helper(item, path): path.pop() visited.remove(item) - for root in roots: - root.state["Start"] = "True" - self.view.arrange_tree(root, 0, 0, 150) - if root not in visited: - dfs_helper(root, []) - root.setBrush("green") + root.state["Start"] = "True" + self.view.arrange_tree(root, 0, 0, 150) + if root not in visited: + dfs_helper(root, []) + root.setBrush("green") + self.root = root - def call_popup(self, rect=None, edit=False): - """Calls popup on click of CustomItem, or if 'Add Basic' button is pressed + def call_basic_popup(self, rect=None, edit=False): + """Calls basic popup on click of CustomItem, or if 'Add Basic' button is pressed Args: rect (CustomItem): CustomItem associated with the popup needed @@ -379,7 +391,7 @@ def call_popup(self, rect=None, edit=False): if rect: if not rect.popup or isinstance(rect.popup, AdvancedPopup): # make a new popup - rect.popup = PopupWindow( + rect.popup = BasicPopup( payloads, state_names=self.scene.getStateNames(), rect=rect, @@ -388,7 +400,7 @@ def call_popup(self, rect=None, edit=False): rect.popup.edit_mode(payloads) self.popup = rect.popup else: - self.popup = PopupWindow(payloads, state_names=self.scene.getStateNames()) + self.popup = BasicPopup(payloads, state_names=self.scene.getStateNames()) if edit and rect: try: @@ -414,11 +426,11 @@ def call_advanced_popup(self, rect=None, edit=False): self.popup.exec() def item_clicked(self): - """When a CustomItem is clicked, calls self.call_popup in order to display popup associated with the CustomItem clicked""" + """When a CustomItem is clicked, calls self.call_basic_popup in order to display popup associated with the CustomItem clicked""" if self.view.itemClicked: rect = self.view.itemClicked if self.setting == "basic": - self.call_popup(rect, True) + self.call_basic_popup(rect, True) elif self.setting == "advanced": self.call_advanced_popup(rect, True) From c2eeaf142c677af76eba74e5e6348c983b9f347c Mon Sep 17 00:00:00 2001 From: Julian Slane Date: Wed, 3 Apr 2024 14:46:20 -0700 Subject: [PATCH 2/9] WIP commit --- constrain/app/import_form.py | 19 ++++++++++--- constrain/app/meta_form.py | 53 +++++++++++++++-------------------- constrain/app/rect_connect.py | 4 +-- 3 files changed, 39 insertions(+), 37 deletions(-) diff --git a/constrain/app/import_form.py b/constrain/app/import_form.py index d50f4b02..7dc21d4d 100644 --- a/constrain/app/import_form.py +++ b/constrain/app/import_form.py @@ -10,6 +10,7 @@ ) from PyQt6.QtGui import QAction from PyQt6.QtCore import Qt +from constrain.app import utils class ImportForm(QWidget): @@ -63,12 +64,22 @@ def read_import(self, imports): Args: imports (list): A list of python imports """ - if isinstance(imports, list) and all(isinstance(item, str) for item in imports): - for i in imports: - self.imports.append(i) - self.import_list.addItem(i) + try: + self.verify_import(imports) + except AssertionError: + utils.send_error("Error in Import", "Invalid imports form") + return + + for i in imports: + self.imports.append(i) + self.import_list.addItem(i) self.update() + def verify_import(self, imports): + assert isinstance(imports, list) and all( + isinstance(item, str) for item in imports + ) + def show_context_menu(self, position): """Allows user to delete an import on right click of an item on the list Args: diff --git a/constrain/app/meta_form.py b/constrain/app/meta_form.py index eecdaeb1..618223c0 100644 --- a/constrain/app/meta_form.py +++ b/constrain/app/meta_form.py @@ -8,6 +8,7 @@ QHBoxLayout, ) from PyQt6.QtCore import QDate +from constrain.app import utils class MetaForm(QWidget): @@ -58,42 +59,32 @@ def get_meta(self): } def read_import(self, workflow_name=None, meta=None): - def isStr(input): + try: + self.verify_import(workflow_name=workflow_name, meta=meta) + except AssertionError: + utils.send_error("Error in Import", "Invalid meta form") + return + + self.name_input.setText(workflow_name) + self.author_input.setText(meta.get("author")) + d = QDate.fromString(meta.get("date"), self.date_format) + self.date_input.setDate(d) + self.version_input.setText(meta.get("version")) + self.description_input.setText(meta.get("description")) + + self.update() + + def verify_import(self, workflow_name=None, meta=None): + def is_str(input): return isinstance(input, str) if workflow_name: - if isStr(workflow_name): - self.name_input.setText(workflow_name) - else: - print("error") + assert is_str(workflow_name) if isinstance(meta, dict): - if "author" in meta.keys(): - author = meta["author"] - if isStr(author): - self.author_input.setText(author) - else: - print("invalid author") - if "date" in meta.keys(): - date = meta["date"] - if isStr(date): - d = QDate.fromString(date, self.date_format) - self.date_input.setDate(d) - else: - print("invalid date") - if "version" in meta.keys(): - version = meta["version"] - if isStr(version): - self.version_input.setText(version) - else: - print("invalid version") - if "description" in meta.keys(): - description = meta["description"] - if isStr(description): - self.description_input.setText(description) - else: - print("invalid description") - self.update() + for k in ["author", "date", "version", "description"]: + if v := meta.get(k): + assert is_str(v) def get_workflow_name(self): return self.name_input.text() diff --git a/constrain/app/rect_connect.py b/constrain/app/rect_connect.py index b6d7c0af..c81201d2 100644 --- a/constrain/app/rect_connect.py +++ b/constrain/app/rect_connect.py @@ -10,7 +10,7 @@ from PyQt6 import QtCore, QtGui, QtWidgets -from . import utils +from constrain.app import utils class Path(QtWidgets.QGraphicsPathItem): @@ -444,7 +444,7 @@ def contextMenuEvent(self, event): # make sure that payloads from self.state are not being used by another state for created_object in objects_created: if created_object in all_objects_in_use: - self.sendError("Object created in use") + utils.send_error("Error in State", "Object created in use") return # remove lines From 7aa54fc898e62fcac93b675bf60f5381e54d0f20 Mon Sep 17 00:00:00 2001 From: Julian Slane Date: Thu, 11 Apr 2024 10:54:20 -0700 Subject: [PATCH 3/9] Made it so that middle states cannot be start or end states --- constrain/app/advanced_popup.py | 11 ++- constrain/app/app.py | 76 +-------------- constrain/app/rect_connect.py | 42 ++------- constrain/app/utils.py | 2 +- constrain/app/workflow_diagram.py | 149 ++++++++++++++++++++++++++---- 5 files changed, 151 insertions(+), 129 deletions(-) diff --git a/constrain/app/advanced_popup.py b/constrain/app/advanced_popup.py index 0564f4de..702076aa 100644 --- a/constrain/app/advanced_popup.py +++ b/constrain/app/advanced_popup.py @@ -90,14 +90,14 @@ def check_state(self): state = self.get_state() if not state: - self.error_popup("Invalid state format") + utils.send_error("Error in State", "Invalid state format") self.error = True else: state_type = state.get("Type") if state_type not in ["Choice", "MethodCall"]: self.error = True - self.error_popup("Invalid type") + utils.send_error("Error in State", "Invalid state type") elif state_type == "Choice": if "Choices" not in state: self.error = True @@ -118,4 +118,9 @@ def check_state(self): "Error in State", "Cannot have both Start and End keys in a state", ) - self.close() + + if state.get("End") and state.get("Next"): + self.error = True + utils.send_error( + "Error in State", "State has 'End' and 'Next' keys" + ) diff --git a/constrain/app/app.py b/constrain/app/app.py index 45004f95..87fd45bb 100644 --- a/constrain/app/app.py +++ b/constrain/app/app.py @@ -169,7 +169,7 @@ def exportFile(self): if fp: try: - workflow = self.get_workflow() + workflow = self.states_form.get_workflow(reformat=False) with open(fp, "w", encoding="utf-8") as f: json.dump(self.create_json(workflow), f, indent=4) except Exception: @@ -228,7 +228,7 @@ def importFile(self): ) self.import_form.read_import(workflow.get("imports")) self.states_form.read_import(workflow.get("states")) - self.get_workflow() + self.states_form.get_workflow(reformat=True) else: # error if selected file cannot be converted to a dict print("error") @@ -281,7 +281,7 @@ def submit_form(self): """Workflow for submitting the state. Triggered on the click of the Submit button. Displays a popup which shows the progress of running the state. """ - states = self.get_workflow(reformat=False) + states = self.states_form.get_workflow(reformat=False) json_data = self.create_json(states) popup = SubmitPopup() @@ -293,79 +293,11 @@ def submit_form(self): self.worker.start() popup.exec() - def get_workflow(self, reformat=True): - """Organizes the states into a single list, colors states based on placing, returns organized workflow - - Returns: - list: A compiled version of the state dicts in DFS order - """ - - # find all CustomItems in the scene - items = [ - item - for item in self.states_form.scene.items() - if isinstance(item, CustomItem) - ] - - # prepare for DFS - roots = [] - for i in items: - parent = True - for j in items: - if i in j.children: - parent = False - break - if parent: - roots.append(i) - visited = set() - - paths = [] - - # DFS helper method - def dfs_helper(item, path): - path.append(item) - visited.add(item) - - if item not in items or not item.children: - # item is a leaf node - item.setBrush("red") - item.state["End"] = "True" - paths.append(path[:]) - else: - # item is not a leaf node - item.setBrush() - - for child in item.children: - if child not in visited: - dfs_helper(child, path) - - path.pop() - visited.remove(item) - - for root in roots: - if reformat: - root.state["Start"] = "True" - self.states_form.view.arrange_tree(root, 0, 0, 150) - if root not in visited: - dfs_helper(root, []) - root.setBrush("green") - - workflow_path = [] - visited = set() - - # create final path through workflow - for path in paths: - for node in path: - if node not in visited: - workflow_path.append(node.state) - visited.add(node) - return workflow_path - def validate_form(self): """Workflow for validating the state. Triggered on the click of the Validate button. Enables the submit button if the workflow is validated. """ - workflow_path = self.get_workflow(reformat=False) + workflow_path = self.states_form.get_workflow(reformat=False) json_data = self.create_json(workflow_path) warnings.simplefilter(action="ignore", category=FutureWarning) diff --git a/constrain/app/rect_connect.py b/constrain/app/rect_connect.py index c81201d2..2fefc876 100644 --- a/constrain/app/rect_connect.py +++ b/constrain/app/rect_connect.py @@ -305,8 +305,10 @@ def hoverLeaveEvent(self, event): self.setOpacity(0.3) -class CustomItem(QtWidgets.QGraphicsItem): +class CustomItem(QtWidgets.QGraphicsObject): # ControlPoint outline + deleted = QtCore.pyqtSignal(QtWidgets.QGraphicsObject) + edited = QtCore.pyqtSignal(QtWidgets.QGraphicsObject) controlBrush = QtGui.QBrush(QtGui.QColor(255, 255, 255)) def __init__(self, state, popup=None): @@ -430,45 +432,15 @@ def setBrush(self, color="orange"): def contextMenuEvent(self, event): """Context menu to allow deletion of self in scene""" menu = QtWidgets.QMenu() + edit_action = menu.addAction("Edit") delete_action = menu.addAction("Delete") action = menu.exec(event.screenPos()) if action == delete_action: - # find payloads that self.state has created - objects_created = self.get_objects_created() - - # find what objects are currently being used by other states - all_objects_in_use = self.scene().getObjectsinUse() - - # make sure that payloads from self.state are not being used by another state - for created_object in objects_created: - if created_object in all_objects_in_use: - utils.send_error("Error in State", "Object created in use") - return - - # remove lines - for c in self.controls: - for p in c.paths: - p1 = p.start - p2 = p.end - if p1 in self.controls: - p2.removeLine(p) - else: - p1.removeLine(p) - self.scene().removeItem(self) - - def sendError(self, text): - """Displays an error message given text - - Args: - text (str): error message to display - """ - error_msg = QtWidgets.QMessageBox() - error_msg.setIcon(QtWidgets.QMessageBox.Icon.Critical) - error_msg.setWindowTitle("Error in State") - error_msg.setText(text) - error_msg.exec() + self.deleted.emit(self) + elif action == edit_action: + self.edited.emit(self) def get_objects_created(self): """Returns objects that self.state has created diff --git a/constrain/app/utils.py b/constrain/app/utils.py index 1559949c..03235e3a 100644 --- a/constrain/app/utils.py +++ b/constrain/app/utils.py @@ -14,7 +14,7 @@ def send_error(window_title, text): error_msg.exec() -def send_are_you_sure(text): +def send_are_you_sure(text=""): """Displays an 'are you sure' message with given text Args: diff --git a/constrain/app/workflow_diagram.py b/constrain/app/workflow_diagram.py index 36b39fa4..f5e80afb 100644 --- a/constrain/app/workflow_diagram.py +++ b/constrain/app/workflow_diagram.py @@ -236,9 +236,6 @@ def add_state(self): return state = self.popup.get_state() - if not state or (state.get("Type", 0) not in ["Choice", "MethodCall"]): - return - self.create_item(state) def create_item(self, state): @@ -249,15 +246,26 @@ def create_item(self, state): """ # test whether state was made with popup, or if it was imported. Adds self.popup to CustomItem if not imported + if self.popup and self.popup.get_state(): + self.popup.get_state()["Title"] + + if self.popup and self.popup.get_state(): + state["Title"] + if ( self.popup and self.popup.get_state() and self.popup.get_state()["Title"] == state["Title"] ): + popup_used = True rect_item = CustomItem(state, popup=self.popup) else: + popup_used = False rect_item = CustomItem(state) + rect_item.deleted.connect(self.delete_state) + rect_item.edited.connect(self.edit_item) + def connect_rects(parent, child): """Connects a parent CustomItem to a child CustomItem @@ -272,7 +280,24 @@ def connect_rects(parent, child): self.scene.addItem(path) # find CustomItems in scene - rects = [item for item in self.scene.items() if isinstance(item, CustomItem)] + rects = self.get_rects_in_scene() + + if state.get("End"): + if self.is_new_end_state_valid(rect_item): + rect_item.setBrush("red") + else: + utils.send_error( + "Error in Workflow", "This item cannot be an end state" + ) + return + elif state.get("Start"): + if self.is_new_start_state_valid(rect_item): + rect_item.setBrush("green") + else: + utils.send_error( + "Error in Workflow", "This item cannot be a start state" + ) + return # if parent and child exist in the scene, connect them with a Path if "Next" in state.keys(): @@ -282,6 +307,7 @@ def connect_rects(parent, child): if len(matching_rects) == 1: child_rect = matching_rects[0] connect_rects(rect_item, child_rect) + for rect in rects: nexts = rect.get_nexts() for next in nexts: @@ -300,8 +326,56 @@ def connect_rects(parent, child): rect_with_max_y = rect size = rect_with_max_y.boundingRect().height() rect_item.setPos(0, max_y + size + stepsize) + + if popup_used: + self.popup.close() + self.update() + def delete_state(self, obj): + objects_created = obj.get_objects_created() + all_objects_in_use = self.scene.getObjectsinUse() + for created_object in objects_created: + if created_object in all_objects_in_use: + utils.send_error("Error in State", "Object created in use") + return + + # remove lines + for c in obj.controls: + for p in c.paths: + p1 = p.start + p2 = p.end + if p1 in obj.controls: + p2.removeLine(p) + else: + p1.removeLine(p) + + self.scene.removeItem(obj) + + def is_new_start_state_valid(self, rect): + rects = self.get_rects_in_scene() + start_or_end_state_rects = [ + rect + for rect in rects + if rect.state.get("Start") and rect.state["Start"] == "True" + ] + + rect_has_no_parents = not any( + rect in possible_parents.children for possible_parents in rects + ) + return len(start_or_end_state_rects) == 0 and rect_has_no_parents + + def get_rect_parents(self, rect): + return [ + parent for parent in self.get_rects_in_scene() if rect in parent.children + ] + + def is_new_end_state_valid(self, rect): + return len(rect.children) == 0 + + def get_rects_in_scene(self): + return [item for item in self.scene.items() if isinstance(item, CustomItem)] + def edit_state(self, rect): """Gives previously made CustomItem a new state @@ -328,9 +402,31 @@ def edit_state(self, rect): if old_state != current_state: rect.set_state(current_state) - def get_workflow(self): + if current_state.get("End"): + if self.is_new_end_state_valid(rect): + rect.setBrush("red") + else: + utils.send_error( + "Error in Workflow", "This item cannot be an end state" + ) + return + elif current_state.get("Start"): + if self.is_new_start_state_valid(rect): + rect.setBrush("green") + else: + utils.send_error( + "Error in Workflow", "This item cannot be a start state" + ) + return + + self.popup.close() + + def get_workflow(self, reformat=True): """Computes structure of the workflow using Depth First Search and paints CustomItems depending on place in graph""" - items = [item for item in self.scene.items() if isinstance(item, CustomItem)] + if not reformat: + reformat = True + + items = self.get_rects_in_scene() roots = [] for i in items: @@ -350,35 +446,47 @@ def get_workflow(self): else: root = roots[0] - visited = set() + visited1 = set() paths = [] def dfs_helper(item, path): path.append(item) - visited.add(item) + visited1.add(item) if item not in items or not item.children: # item is a leaf node item.setBrush("red") item.state["End"] = "True" + item.state.pop("Next", None) paths.append(path[:]) else: # item is not a leaf node item.setBrush() for child in item.children: - if child not in visited: + if child not in visited1: dfs_helper(child, path) path.pop() - visited.remove(item) + visited1.remove(item) - root.state["Start"] = "True" - self.view.arrange_tree(root, 0, 0, 150) - if root not in visited: + if reformat: + self.view.arrange_tree(root, 0, 0, 150) + if root not in visited1: dfs_helper(root, []) - root.setBrush("green") + self.root = root + root.state["Start"] = "True" + root.setBrush("green") + + visited2 = set() + workflow_path = [] + for path in paths: + for node in path: + if node not in visited2: + workflow_path.append(node.state) + visited2.add(node) + return workflow_path def call_basic_popup(self, rect=None, edit=False): """Calls basic popup on click of CustomItem, or if 'Add Basic' button is pressed @@ -429,10 +537,13 @@ def item_clicked(self): """When a CustomItem is clicked, calls self.call_basic_popup in order to display popup associated with the CustomItem clicked""" if self.view.itemClicked: rect = self.view.itemClicked - if self.setting == "basic": - self.call_basic_popup(rect, True) - elif self.setting == "advanced": - self.call_advanced_popup(rect, True) + self.edit_item(rect) + + def edit_item(self, rect): + if self.setting == "basic": + self.call_basic_popup(rect, True) + elif self.setting == "advanced": + self.call_advanced_popup(rect, True) def read_import(self, states): """Adds imported states to workflow diagram @@ -453,5 +564,7 @@ def contains_data(self): def clear(self): """Clear all state""" self.scene.clear() + self.popup = None + self.root = None self.view.resetTransform() self.update() From f322778c7575713684a8f09331f4b2bea85b7f6f Mon Sep 17 00:00:00 2001 From: Julian Slane Date: Thu, 25 Apr 2024 12:16:04 -0700 Subject: [PATCH 4/9] wip: fixing state deletion logic --- constrain/app/workflow_diagram.py | 38 ++++++++++++++++++------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/constrain/app/workflow_diagram.py b/constrain/app/workflow_diagram.py index 46df0161..28ace91c 100644 --- a/constrain/app/workflow_diagram.py +++ b/constrain/app/workflow_diagram.py @@ -7,6 +7,7 @@ QGraphicsTextItem, QGraphicsRectItem, QMenu, + QGraphicsObject, ) from PyQt6.QtCore import Qt, pyqtSignal, QRectF from PyQt6.QtGui import ( @@ -29,6 +30,7 @@ class Zoom(QGraphicsView): clicked = pyqtSignal() + mass_deleted = pyqtSignal(QGraphicsObject) def __init__(self, scene): """QGraphicsView that includes zoom function @@ -78,24 +80,28 @@ def keyPressEvent(self, event: QKeyEvent): def contextMenuEvent(self, event): menu = QMenu(self) - + delete_action = menu.addAction(delete_action) + action = menu.exec(event.screenPos()) # Check if there's an item under the mouse cursor - selected_states = [ - item - for item in self.scene.items() - if item.isSelected() and isinstance(item, CustomItem) - ] - if selected_states: - delete_action = QAction("Delete", self) - delete_action.triggered.connect(lambda: self.delete_items(selected_states)) - menu.addAction(delete_action) - else: - item = self.itemAt(event.pos()) - if isinstance(item, CustomItem): + if action == delete_action: + selected_states = [ + item + for item in self.scene.items() + if item.isSelected() and isinstance(item, CustomItem) + ] + if selected_states: delete_action = QAction("Delete", self) - delete_action.triggered.connect(item.delete) - menu.addAction(delete_action) + self.mass_deleted.emit(selected_states) + # delete_action.triggered.connect(lambda: self.delete_items(selected_states)) + + else: + item = self.itemAt(event.pos()) + + if isinstance(item, CustomItem): + delete_action = QAction("Delete", self) + delete_action.triggered.connect(item.delete) + menu.addAction(delete_action) menu.exec(event.globalPos()) @@ -119,7 +125,7 @@ def delete_items(self, item_list): error_msg = f"{error_msg_object} being used by other state" if len(intersection) > 1: error_msg += "s" - send_error("Error deleting state", error_msg) + utils.send_error("Error Deleting State", error_msg) return for item in item_list: From 3326d7cce4771e534cc2f56102ee56d8e8a46f4b Mon Sep 17 00:00:00 2001 From: Julian Slane Date: Wed, 8 May 2024 14:19:46 -0700 Subject: [PATCH 5/9] Fixed deletion of multiple states --- constrain/app/workflow_diagram.py | 45 +++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/constrain/app/workflow_diagram.py b/constrain/app/workflow_diagram.py index 28ace91c..bc563651 100644 --- a/constrain/app/workflow_diagram.py +++ b/constrain/app/workflow_diagram.py @@ -30,7 +30,7 @@ class Zoom(QGraphicsView): clicked = pyqtSignal() - mass_deleted = pyqtSignal(QGraphicsObject) + mass_deleted = pyqtSignal(list) def __init__(self, scene): """QGraphicsView that includes zoom function @@ -80,8 +80,8 @@ def keyPressEvent(self, event: QKeyEvent): def contextMenuEvent(self, event): menu = QMenu(self) - delete_action = menu.addAction(delete_action) - action = menu.exec(event.screenPos()) + delete_action = menu.addAction("Delete") + action = menu.exec(event.globalPos()) # Check if there's an item under the mouse cursor if action == delete_action: @@ -93,17 +93,6 @@ def contextMenuEvent(self, event): if selected_states: delete_action = QAction("Delete", self) self.mass_deleted.emit(selected_states) - # delete_action.triggered.connect(lambda: self.delete_items(selected_states)) - - else: - item = self.itemAt(event.pos()) - - if isinstance(item, CustomItem): - delete_action = QAction("Delete", self) - delete_action.triggered.connect(item.delete) - menu.addAction(delete_action) - - menu.exec(event.globalPos()) def delete_items(self, item_list): all_objects_in_use = Counter(self.scene.getObjectsinUse()) @@ -246,6 +235,8 @@ def __init__(self, setting): layout = QVBoxLayout(self) self.scene = Scene() self.view = Zoom(self.scene) + + self.view.mass_deleted.connect(self.delete_states) self.view.clicked.connect(self.item_clicked) # last popup accessed @@ -389,6 +380,32 @@ def connect_rects(parent, child): self.update() + def delete_states(self, obj_list): + all_objects_in_use = Counter(self.scene.getObjectsinUse()) + objects_used_in_items = Counter( + [ + item_object + for item in obj_list + for item_object in item.get_objects_used() + ] + ) + objects_not_used_in_items = set(all_objects_in_use - objects_used_in_items) + objects_created_in_items = set() + for item in obj_list: + objects_created_in_items |= set(item.get_objects_created()) + + intersection = objects_created_in_items & objects_not_used_in_items + if intersection: + error_msg_object = ", ".join(intersection) + error_msg = f"{error_msg_object} being used by other state" + if len(intersection) > 1: + error_msg += "s" + utils.send_error("Error Deleting State", error_msg) + return + + for item in obj_list: + self.delete_state(item) + def delete_state(self, obj): objects_created = obj.get_objects_created() all_objects_in_use = self.scene.getObjectsinUse() From 928b1ed2217a3fabcc38b35a98217dd8ca6acf03 Mon Sep 17 00:00:00 2001 From: Julian Slane Date: Wed, 8 May 2024 15:17:31 -0700 Subject: [PATCH 6/9] Removed unnecessary function --- constrain/app/workflow_diagram.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/constrain/app/workflow_diagram.py b/constrain/app/workflow_diagram.py index bc563651..b57d640e 100644 --- a/constrain/app/workflow_diagram.py +++ b/constrain/app/workflow_diagram.py @@ -94,32 +94,6 @@ def contextMenuEvent(self, event): delete_action = QAction("Delete", self) self.mass_deleted.emit(selected_states) - def delete_items(self, item_list): - all_objects_in_use = Counter(self.scene.getObjectsinUse()) - objects_used_in_items = Counter( - [ - item_object - for item in item_list - for item_object in item.get_objects_used() - ] - ) - objects_not_used_in_items = set(all_objects_in_use - objects_used_in_items) - objects_created_in_items = set() - for item in item_list: - objects_created_in_items |= set(item.get_objects_created()) - - intersection = objects_created_in_items & objects_not_used_in_items - if intersection: - error_msg_object = ", ".join(intersection) - error_msg = f"{error_msg_object} being used by other state" - if len(intersection) > 1: - error_msg += "s" - utils.send_error("Error Deleting State", error_msg) - return - - for item in item_list: - item.delete() - def mouseDoubleClickEvent(self, event): super().mouseDoubleClickEvent(event) item = self.itemAt(event.pos()) From eca5a0b5250b0ca92d3809cdc59a153ab6bee0a1 Mon Sep 17 00:00:00 2001 From: Julian Slane Date: Fri, 4 Oct 2024 08:54:01 -0700 Subject: [PATCH 7/9] Add functionality to edit state from context menu --- constrain/app/workflow_diagram.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/constrain/app/workflow_diagram.py b/constrain/app/workflow_diagram.py index b57d640e..ac13f40d 100644 --- a/constrain/app/workflow_diagram.py +++ b/constrain/app/workflow_diagram.py @@ -31,6 +31,7 @@ class Zoom(QGraphicsView): clicked = pyqtSignal() mass_deleted = pyqtSignal(list) + edit = pyqtSignal(CustomItem) def __init__(self, scene): """QGraphicsView that includes zoom function @@ -81,18 +82,26 @@ def keyPressEvent(self, event: QKeyEvent): def contextMenuEvent(self, event): menu = QMenu(self) delete_action = menu.addAction("Delete") + edit_action = menu.addAction("Edit") action = menu.exec(event.globalPos()) # Check if there's an item under the mouse cursor - if action == delete_action: - selected_states = [ - item - for item in self.scene.items() - if item.isSelected() and isinstance(item, CustomItem) - ] - if selected_states: + selected_states = [ + item + for item in self.scene.items() + if item.isSelected() and isinstance(item, CustomItem) + ] + if selected_states: + if action == delete_action: delete_action = QAction("Delete", self) self.mass_deleted.emit(selected_states) + elif action == edit_action: + if len(selected_states) > 1: + utils.send_error("Error Editing States", "Select only 1 state to edit") + else: + self.edit.emit(selected_states[0]) + + def mouseDoubleClickEvent(self, event): super().mouseDoubleClickEvent(event) @@ -211,6 +220,7 @@ def __init__(self, setting): self.view = Zoom(self.scene) self.view.mass_deleted.connect(self.delete_states) + self.view.edit.connect(self.edit_item) self.view.clicked.connect(self.item_clicked) # last popup accessed From 0ab7ca7399d9e74438969ccf586973bffcae2247 Mon Sep 17 00:00:00 2001 From: Julian Slane Date: Fri, 4 Oct 2024 09:19:34 -0700 Subject: [PATCH 8/9] Only show edit action if 1 state is selected --- constrain/app/workflow_diagram.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/constrain/app/workflow_diagram.py b/constrain/app/workflow_diagram.py index ac13f40d..a31adee0 100644 --- a/constrain/app/workflow_diagram.py +++ b/constrain/app/workflow_diagram.py @@ -81,10 +81,6 @@ def keyPressEvent(self, event: QKeyEvent): def contextMenuEvent(self, event): menu = QMenu(self) - delete_action = menu.addAction("Delete") - edit_action = menu.addAction("Edit") - action = menu.exec(event.globalPos()) - # Check if there's an item under the mouse cursor selected_states = [ item @@ -92,14 +88,19 @@ def contextMenuEvent(self, event): if item.isSelected() and isinstance(item, CustomItem) ] if selected_states: + if len(selected_states) > 1: + delete_action = menu.addAction("Delete") + if len(selected_states) == 1: + edit_action = menu.addAction("Edit") + delete_action = menu.addAction("Delete") + + action = menu.exec(event.globalPos()) + if action == delete_action: delete_action = QAction("Delete", self) self.mass_deleted.emit(selected_states) - elif action == edit_action: - if len(selected_states) > 1: - utils.send_error("Error Editing States", "Select only 1 state to edit") - else: - self.edit.emit(selected_states[0]) + elif len(selected_states) == 1 and action == edit_action: + self.edit.emit(selected_states[0]) From d95b5cfa5332487647d8ab759d5427a26632c162 Mon Sep 17 00:00:00 2001 From: Julian Slane Date: Fri, 4 Oct 2024 09:23:15 -0700 Subject: [PATCH 9/9] Black formatting --- constrain/app/workflow_diagram.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/constrain/app/workflow_diagram.py b/constrain/app/workflow_diagram.py index a31adee0..6b9599c6 100644 --- a/constrain/app/workflow_diagram.py +++ b/constrain/app/workflow_diagram.py @@ -95,15 +95,13 @@ def contextMenuEvent(self, event): delete_action = menu.addAction("Delete") action = menu.exec(event.globalPos()) - + if action == delete_action: delete_action = QAction("Delete", self) self.mass_deleted.emit(selected_states) elif len(selected_states) == 1 and action == edit_action: self.edit.emit(selected_states[0]) - - def mouseDoubleClickEvent(self, event): super().mouseDoubleClickEvent(event) item = self.itemAt(event.pos())