diff --git a/opencodeblocks/blocks/codeblock.py b/opencodeblocks/blocks/codeblock.py index db6ce55a..229f5681 100644 --- a/opencodeblocks/blocks/codeblock.py +++ b/opencodeblocks/blocks/codeblock.py @@ -3,10 +3,19 @@ """ Module for the base OCB Code Block. """ -from typing import OrderedDict -from PyQt5.QtWidgets import QPushButton, QTextEdit +from typing import OrderedDict, Optional +from PyQt5.QtWidgets import ( + QApplication, + QPushButton, + QTextEdit, + QWidget, + QStyleOptionGraphicsItem, +) +from PyQt5.QtCore import QTimer, Qt +from PyQt5.QtGui import QPen, QColor, QPainter, QPainterPath from ansi2html import Ansi2HTMLConverter + from opencodeblocks.blocks.block import OCBBlock from opencodeblocks.blocks.executableblock import OCBExecutableBlock @@ -54,6 +63,18 @@ def __init__(self, source: str = "", **kwargs): self.output_closed = True self._splitter_size = [1, 1] + self._cached_stdout = "" + self.has_been_run = False + self.blocks_to_run = [] + + self._pen_outline = QPen(QColor("#7F000000")) + self._pen_outline_running = QPen(QColor("#FF0000")) + self._pen_outline_transmitting = QPen(QColor("#00ff00")) + self._pen_outlines = [ + self._pen_outline, + self._pen_outline_running, + self._pen_outline_transmitting, + ] # Add output pannel self.output_panel = self.init_output_panel() @@ -97,20 +118,21 @@ def init_run_all_button(self): def handle_run_right(self): """Called when the button for "Run All" was pressed""" - if self.is_running: + if self.run_color != 0: self._interrupt_execution() else: self.run_right() def handle_run_left(self): """Called when the button for "Run Left" was pressed""" - if self.is_running: + if self.run_color != 0: self._interrupt_execution() else: self.run_left() def run_code(self): """Run the code in the block""" + # Reset stdout self._cached_stdout = "" @@ -120,12 +142,6 @@ def run_code(self): super().run_code() # actually run the code - def execution_finished(self): - """Reset the text of the run buttons""" - super().execution_finished() - self.run_button.setText(">") - self.run_all_button.setText(">>") - def update_title(self): """Change the geometry of the title widget""" self.title_widget.setGeometry( @@ -156,6 +172,35 @@ def update_all(self): self.update_output_panel() self.update_run_all_button() + def paint( + self, + painter: QPainter, + option: QStyleOptionGraphicsItem, + widget: Optional[QWidget] = None, + ): + """Paint the code block""" + path_content = QPainterPath() + path_content.setFillRule(Qt.FillRule.WindingFill) + path_content.addRoundedRect( + 0, 0, self.width, self.height, self.edge_size, self.edge_size + ) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(self._brush_background) + painter.drawPath(path_content.simplified()) + + # outline + path_outline = QPainterPath() + path_outline.addRoundedRect( + 0, 0, self.width, self.height, self.edge_size, self.edge_size + ) + painter.setPen( + self._pen_outline_selected + if self.isSelected() + else self._pen_outlines[self.run_color] + ) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawPath(path_outline.simplified()) + @property def source(self) -> str: """Source code""" @@ -168,6 +213,17 @@ def source(self, value: str): self.source_editor.setText(value) self._source = value + @property + def run_color(self) -> int: + """Run color""" + return self._run_color + + @run_color.setter + def run_color(self, value: int): + self._run_color = value + # Update to force repaint + self.update() + @property def stdout(self) -> str: """Access the content of the output panel of the block""" @@ -231,6 +287,7 @@ def handle_image(self, image: str): self.stdout = "" + image def serialize(self): + """Serialize the code block""" base_dict = super().serialize() base_dict["source"] = self.source base_dict["stdout"] = self.stdout diff --git a/opencodeblocks/blocks/executableblock.py b/opencodeblocks/blocks/executableblock.py index 5f228142..a6adb77e 100644 --- a/opencodeblocks/blocks/executableblock.py +++ b/opencodeblocks/blocks/executableblock.py @@ -1,7 +1,9 @@ """ Module for the executable block class """ -from typing import List, OrderedDict +from typing import OrderedDict from abc import abstractmethod +from PyQt5.QtCore import QTimer +from PyQt5.QtWidgets import QApplication from networkx.algorithms.traversal.breadth_first_search import bfs_edges @@ -30,7 +32,15 @@ def __init__(self, **kwargs): super().__init__(**kwargs) self.has_been_run = False - self.is_running = False + + # 0 for normal, 1 for running, 2 for transmitting + self.run_color = 0 + + # Each element is a list of blocks/edges to be animated + # Running will paint each element one after the other + self.transmitting_queue = [] + # Controls the duration of the visual flow animation + self.transmitting_duration = 500 # Add execution flow sockets exe_sockets = ( @@ -65,65 +75,232 @@ def run_code(self): kernel = self.scene().kernel kernel.execution_queue.append((self, code)) - self.is_running = True - if kernel.busy is False: kernel.run_queue() self.has_been_run = True def execution_finished(self): - """ - Method called when the execution of the block is finished. - Implement the behavior you want here. - """ - self.is_running = False + """Reset the text of the run buttons""" + self.run_color = 0 + self.run_button.setText(">") + self.run_all_button.setText(">>") + self.blocks_to_run = [] def _interrupt_execution(self): """Interrupt an execution, reset the blocks in the queue""" - kernel = self.scene().kernel - for block, _ in kernel.execution_queue: + for block, _ in self.scene().kernel.execution_queue: # Reset the blocks that have not been run + block.reset_has_been_run() block.execution_finished() - block.has_been_run = False - # Clear the queue - kernel.execution_queue = [] + # Clear kernel execution queue + self.scene().kernel.execution_queue = [] # Interrupt the kernel - kernel.kernel_manager.interrupt_kernel() + self.scene().kernel.kernel_manager.interrupt_kernel() + # Clear local execution queue + self.blocks_to_run = [] - def run_left(self): + def transmitting_animation_in(self): + """ + Animate the visual flow + Set color to transmitting and set a timer before switching to normal + """ + for elem in self.transmitting_queue[0]: + # Set color to transmitting + elem.run_color = 2 + QApplication.processEvents() + QTimer.singleShot(self.transmitting_delay, self.transmitting_animation_out) + + def transmitting_animation_out(self): + """ + Animate the visual flow + After the timer, set color to normal and move on with the queue + """ + for elem in self.transmitting_queue[0]: + # Reset color only if the block will not be run + if hasattr(elem, "has_been_run"): + if elem.has_been_run is True: + elem.run_color = 0 + else: + elem.run_color = 0 + + QApplication.processEvents() + self.transmitting_queue.pop(0) + if len(self.transmitting_queue) != 0: + # If the queue is not empty, move forward in the animation + self.transmitting_animation_in() + else: + # Else, run the blocks in the self.blocks_to_run + self.run_blocks() + + def custom_bfs(self, start_node, reverse=False): + """ + Graph traversal in BFS to find the blocks that are connected to the start_node + + Args: + start_node (Block): The block to start the traversal from + reverse (bool): If True, traverse in the direction of outputs + + Returns: + list: Blocks to run in topological order (reversed) + list: each element is a list of blocks/edges to animate in order + """ + # Blocks to run in topological order + blocks_to_run = [] + # List of lists of blocks/edges to animate in order + to_transmit = [[start_node]] + + to_visit = [start_node] + while len(to_visit) != 0: + # Remove duplicates + to_visit = list(set(to_visit)) + + # Gather connected edges + edges_to_visit = [] + for block in to_visit: + blocks_to_run.append(block) + if not reverse: + for input_socket in block.sockets_in: + for edge in input_socket.edges: + edges_to_visit.append(edge) + else: + for output_socket in block.sockets_out: + for edge in output_socket.edges: + edges_to_visit.append(edge) + to_transmit.append(edges_to_visit) + + # Gather connected blocks + to_visit = [] + for edge in edges_to_visit: + if not reverse: + to_visit.append(edge.source_socket.block) + else: + to_visit.append(edge.destination_socket.block) + to_transmit.append(to_visit) + + # Remove start node + blocks_to_run.pop(0) + + return blocks_to_run, to_transmit + + def right_traversal(self): """ - Run all of the block's dependencies and then run the block + Custom graph traversal utility + Returns blocks/edges that will potentially be run by run_right + from closest to farthest from self + + Returns: + list: each element is a list of blocks/edges to animate in order """ + # Result + to_transmit = [[self]] + + # To check if a block has been visited + visited = [] + # We need to visit the inputs of these blocks + to_visit_input = [self] + # We need to visit the outputs of these blocks + to_visit_output = [self] + + # Next stage to put in to_transmit + next_edges = [] + next_blocks = [] + + while len(to_visit_input) != 0 or len(to_visit_output) != 0: + for block in to_visit_input.copy(): + # Check input edges and blocks + for input_socket in block.sockets_in: + for edge in input_socket.edges: + if edge not in visited: + next_edges.append(edge) + visited.append(edge) + input_block = edge.source_socket.block + to_visit_input.append(input_block) + if input_block not in visited: + next_blocks.append(input_block) + visited.append(input_block) + to_visit_input.remove(block) + for block in to_visit_output.copy(): + # Check output edges and blocks + for output_socket in block.sockets_out: + for edge in output_socket.edges: + if edge not in visited: + next_edges.append(edge) + visited.append(edge) + output_block = edge.destination_socket.block + to_visit_input.append(output_block) + to_visit_output.append(output_block) + if output_block not in visited: + next_blocks.append(output_block) + visited.append(output_block) + to_visit_output.remove(block) + + # Add the next stage to to_transmit + to_transmit.append(next_edges) + to_transmit.append(next_blocks) + + # Reset next stage + next_edges = [] + next_blocks = [] + + return to_transmit + + def run_blocks(self): + """Run a list of blocks""" + for block in self.blocks_to_run[::-1]: + if not block.has_been_run: + block.run_code() + if not self.has_been_run: + self.run_code() + + def run_left(self): + """Run all of the block's dependencies and then run the block""" + + # Reset has_been_run to make sure that the self is run again + self.has_been_run = False - if self.has_input(): - # Create the graph from the scene - graph = self.scene().create_graph() - # BFS through the input graph - edges = bfs_edges(graph, self, reverse=True) - # Run the blocks found except self - blocks_to_run: List["OCBExecutableBlock"] = [v for _, v in edges] - for block in blocks_to_run[::-1]: - if not block.has_been_run: - block.run_code() - - if self.is_running: + # To avoid crashing when spamming the button + if len(self.transmitting_queue) != 0: return - self.run_code() + + # Gather dependencies + blocks_to_run, to_transmit = self.custom_bfs(self) + self.blocks_to_run = blocks_to_run + + # Set the transmitting queue + self.transmitting_queue = to_transmit + # Set delay so that the transmitting animation has fixed total duration + self.transmitting_delay = int( + self.transmitting_duration / len(self.transmitting_queue) + ) + # Start transmitting animation + self.transmitting_animation_in() def run_right(self): """Run all of the output blocks and all their dependencies""" - # If no output, run left - if not self.has_output(): - self.run_left() + # To avoid crashing when spamming the button + if len(self.transmitting_queue) != 0: return - # Same as run_left but instead of running the blocks, we'll use run_left - graph = self.scene().create_graph() - edges = bfs_edges(graph, self) - blocks_to_run: List["OCBExecutableBlock"] = [self] + [v for _, v in edges] - for block in blocks_to_run[::-1]: - block.run_left() + # Create transmitting queue + self.transmitting_queue = self.right_traversal() + + # Gather outputs + blocks_to_run, _ = self.custom_bfs(self, reverse=True) + self.blocks_to_run = blocks_to_run + + # For each output found + for block in blocks_to_run.copy()[::-1]: + # Gather dependencies + new_blocks_to_run, _ = self.custom_bfs(block) + self.blocks_to_run += new_blocks_to_run + + # Set delay so that the transmitting animation has fixed total duration + self.transmitting_delay = int( + self.transmitting_duration / len(self.transmitting_queue) + ) + # Start transmitting animation + self.transmitting_animation_in() def reset_has_been_run(self): """Called when the output is an error""" diff --git a/opencodeblocks/graphics/edge.py b/opencodeblocks/graphics/edge.py index 7c636917..7b178039 100644 --- a/opencodeblocks/graphics/edge.py +++ b/opencodeblocks/graphics/edge.py @@ -9,7 +9,11 @@ from PyQt5.QtCore import QPointF, Qt from PyQt5.QtGui import QColor, QPainter, QPainterPath, QPen -from PyQt5.QtWidgets import QGraphicsPathItem, QStyleOptionGraphicsItem, QWidget +from PyQt5.QtWidgets import ( + QGraphicsPathItem, + QStyleOptionGraphicsItem, + QWidget, +) from opencodeblocks.core.serializable import Serializable from opencodeblocks.graphics.socket import OCBSocket @@ -28,6 +32,8 @@ def __init__( path_type=DEFAULT_DATA["path_type"], edge_color="#001000", edge_selected_color="#00ff00", + edge_running_color="#FF0000", + edge_transmitting_color="#00ff00", source: QPointF = QPointF(0, 0), destination: QPointF = QPointF(0, 0), source_socket: OCBSocket = None, @@ -59,6 +65,17 @@ def __init__( self._pen_selected = QPen(QColor(edge_selected_color)) self._pen_selected.setWidthF(edge_width) + self._pen_running = QPen(QColor(edge_running_color)) + self._pen_running.setWidthF(edge_width) + + self._pen_transmitting = QPen(QColor(edge_transmitting_color)) + self._pen_transmitting.setWidthF(edge_width) + + self.pens = [self._pen, self._pen_running, self._pen_transmitting] + + # 0 for normal, 1 for running, 2 for transmitting + self.run_color = 0 + self.setFlag(QGraphicsPathItem.GraphicsItemFlag.ItemIsSelectable) self.setZValue(-1) @@ -104,8 +121,13 @@ def paint( ): # pylint:disable=unused-argument """Paint the edge.""" self.update_path() - pen = self._pen_dragging if self.destination_socket is None else self._pen - painter.setPen(self._pen_selected if self.isSelected() else pen) + if self.isSelected(): + pen = self._pen_selected + elif self.destination_socket is None: + pen = self._pen_dragging + else: + pen = self.pens[self.run_color] + painter.setPen(pen) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawPath(self.path()) @@ -237,3 +259,14 @@ def deserialize(self, data: OrderedDict, hashmap: dict = None, restore_id=True): self.update_path() except KeyError: self.remove() + + @property + def run_color(self) -> int: + """Run color""" + return self._run_color + + @run_color.setter + def run_color(self, value: int): + self._run_color = value + # Update to force repaint + self.update() diff --git a/opencodeblocks/graphics/kernel.py b/opencodeblocks/graphics/kernel.py index c2f3b446..6cea732d 100644 --- a/opencodeblocks/graphics/kernel.py +++ b/opencodeblocks/graphics/kernel.py @@ -64,6 +64,8 @@ def run_block(self, block, code: str): code: String representing a piece of Python code to execute """ worker = Worker(self, code) + # Change color to running + block.run_color = 1 worker.signals.stdout.connect(block.handle_stdout) worker.signals.image.connect(block.handle_image) worker.signals.finished.connect(self.run_queue) diff --git a/opencodeblocks/scene/scene.py b/opencodeblocks/scene/scene.py index a1a7bf9c..60b86814 100644 --- a/opencodeblocks/scene/scene.py +++ b/opencodeblocks/scene/scene.py @@ -21,11 +21,8 @@ from opencodeblocks.graphics.kernel import Kernel from opencodeblocks.scene.from_ipynb_conversion import ipynb_to_ipyg from opencodeblocks.scene.to_ipynb_conversion import ipyg_to_ipynb - from opencodeblocks import blocks -import networkx as nx - class OCBScene(QGraphicsScene, Serializable): @@ -203,6 +200,7 @@ def clear(self): return super().clear() def serialize(self) -> OrderedDict: + """Serialize the scene into a dict.""" blocks = [] edges = [] for item in self.items(): @@ -220,17 +218,6 @@ def serialize(self) -> OrderedDict: ] ) - def create_graph(self) -> nx.DiGraph: - """Create a networkx graph from the scene.""" - edges = [] - for item in self.items(): - if isinstance(item, OCBEdge): - edges.append(item) - graph = nx.DiGraph() - for edge in edges: - graph.add_edge(edge.source_socket.block, edge.destination_socket.block) - return graph - def create_block_from_file(self, filepath: str, x: float = 0, y: float = 0): """Create a new block from a .ocbb file""" with open(filepath, "r", encoding="utf-8") as file: diff --git a/tests/integration/blocks/test_codeblock.py b/tests/integration/blocks/test_codeblock.py index a6f79a7d..3c304a0a 100644 --- a/tests/integration/blocks/test_codeblock.py +++ b/tests/integration/blocks/test_codeblock.py @@ -51,7 +51,9 @@ def testing_run(msgQueue: CheckingQueue): pyautogui.mouseDown(button="left") pyautogui.mouseUp(button="left") - time.sleep(0.5) + time.sleep((test_block.transmitting_duration / 1000) + 0.2) + while test_block.run_color != 0: + time.sleep(0.1) msgQueue.check_equal(test_block.stdout.strip(), expected_result) msgQueue.stop() @@ -81,7 +83,7 @@ def run_block(): msgQueue.run_lambda(run_block) time.sleep(0.1) # wait for the lambda to complete. - while block_of_test.is_running: + while block_of_test.run_color != 0: time.sleep(0.1) # wait for the execution to finish. time.sleep(0.1) diff --git a/tests/integration/blocks/test_flow.py b/tests/integration/blocks/test_flow.py index 2f041aa3..4bd3747e 100644 --- a/tests/integration/blocks/test_flow.py +++ b/tests/integration/blocks/test_flow.py @@ -47,9 +47,9 @@ def run_block(): block_to_run.run_right() msgQueue.run_lambda(run_block) - time.sleep(0.1) - while block_to_run.is_running: - time.sleep(0.1) # wait for the execution to finish. + time.sleep((block_to_run.transmitting_duration / 1000) + 0.2) + while block_to_run.run_color != 0: + time.sleep(0.1) # 6 and not 6\n6 msgQueue.check_equal(block_to_run.stdout.strip(), "6") @@ -72,9 +72,9 @@ def run_block(): block_to_run.run_left() msgQueue.run_lambda(run_block) - time.sleep(0.1) - while block_to_run.is_running: - time.sleep(0.1) # wait for the execution to finish. + time.sleep((block_to_run.transmitting_duration / 1000) + 0.2) + while block_to_run.run_color != 0: + time.sleep(0.1) msgQueue.check_equal(block_to_run.stdout.strip(), "6") msgQueue.check_equal(block_to_not_run.stdout.strip(), "") @@ -97,9 +97,8 @@ def run_block(): print("About to run !") msgQueue.run_lambda(run_block) - time.sleep(0.1) - while block_to_run.is_running: - print("wait ...") + time.sleep((block_to_run.transmitting_duration / 1000) + 0.2) + while block_to_run.run_color != 0: time.sleep(0.1) msgQueue.check_equal(block_to_run.stdout.strip(), "1") @@ -120,8 +119,8 @@ def run_block(): block_to_run.run_right() msgQueue.run_lambda(run_block) - time.sleep(0.1) - while block_to_run.is_running: + time.sleep((block_to_run.transmitting_duration / 1000) + 0.2) + while block_to_run.run_color != 0: time.sleep(0.1) # Just check that it doesn't crash