diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f52c6233..fbdd09f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,12 @@ Before doing your **pull request**, check using `pylint` and `pytest` that there pylint .\opencodeblocks\ ``` +Some `pylint` issues can be fixed automatically using `autopep8`, with the following command: + +```bash +autopep8 --in-place --recursive --aggressive opencodeblocks +``` + ```bash pytest --cov=opencodeblocks --cov-report=html tests/unit ``` diff --git a/opencodeblocks/graphics/blocks/block.py b/opencodeblocks/graphics/blocks/block.py index 90630bf6..5475a3b9 100644 --- a/opencodeblocks/graphics/blocks/block.py +++ b/opencodeblocks/graphics/blocks/block.py @@ -8,7 +8,7 @@ from PyQt5.QtCore import QPointF, QRectF, Qt from PyQt5.QtGui import QBrush, QPen, QColor, QFont, QPainter, QPainterPath from PyQt5.QtWidgets import QGraphicsItem, QGraphicsSceneMouseEvent, QGraphicsTextItem, \ - QStyleOptionGraphicsItem, QWidget, QApplication + QStyleOptionGraphicsItem, QWidget, QApplication, QGraphicsSceneHoverEvent from opencodeblocks.core.serializable import Serializable from opencodeblocks.graphics.socket import OCBSocket @@ -72,7 +72,10 @@ def __init__(self, block_type:str='base', source:str='', position:tuple=(0, 0), self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable) + self.setAcceptHoverEvents(True) + self.resizing = False + self.resizing_hover = False # Is the mouse hovering over the resizing area ? self.moved = False self.metadata = { 'title_metadata': { @@ -130,8 +133,8 @@ def paint(self, painter: QPainter, def _is_in_resize_area(self, pos:QPointF): """ Return True if the given position is in the block resize_area. """ - return self.width - pos.x() < 2 * self.edge_size \ - and self.height - pos.y() < 2 * self.edge_size + return self.width - self.edge_size*2 < pos.x() \ + and self.height - self.edge_size*2 < pos.y() def get_socket_pos(self, socket:OCBSocket) -> Tuple[float]: """ Get a socket position to place them on the block sides. """ @@ -172,21 +175,51 @@ def remove_socket(self, socket:OCBSocket): socket.remove() self.update_sockets() + def hoverMoveEvent(self, event:QGraphicsSceneHoverEvent): + """ Triggered when hovering over a block """ + pos = event.pos() + if self._is_in_resize_area(pos): + if not self.resizing_hover: + self._start_hovering() + elif self.resizing_hover: + self._stop_hovering() + return super().hoverMoveEvent(event) + + def _start_hovering(self): + self.resizing_hover = True + QApplication.setOverrideCursor(Qt.CursorShape.SizeFDiagCursor) + + def _stop_hovering(self): + self.resizing_hover = False + QApplication.restoreOverrideCursor() + + def _start_resize(self,pos:QPointF): + self.resizing = True + self.resize_start = pos + QApplication.setOverrideCursor(Qt.CursorShape.SizeFDiagCursor) + + def _stop_resize(self): + self.resizing = False + QApplication.setOverrideCursor(Qt.CursorShape.SizeFDiagCursor) + + def hoverLeaveEvent(self, event:QGraphicsSceneHoverEvent): + """ Triggered when the mouse stops hovering over a block """ + if self.resizing_hover: + self._stop_hovering() + return super().hoverLeaveEvent(event) + def mousePressEvent(self, event:QGraphicsSceneMouseEvent): """ OCBBlock reaction to a mousePressEvent. """ pos = event.pos() - if self._is_in_resize_area(pos) and event.buttons() == Qt.MouseButton.LeftButton: - self.resize_start = pos - self.resizing = True - QApplication.setOverrideCursor(Qt.CursorShape.SizeFDiagCursor) + if self.resizing_hover and event.buttons() == Qt.MouseButton.LeftButton: + self._start_resize(pos) super().mousePressEvent(event) def mouseReleaseEvent(self, event:QGraphicsSceneMouseEvent): """ OCBBlock reaction to a mouseReleaseEvent. """ if self.resizing: self.scene().history.checkpoint("Resized block", set_modified=True) - self.resizing = False - QApplication.restoreOverrideCursor() + self._stop_resize() if self.moved: self.moved = False self.scene().history.checkpoint("Moved block", set_modified=True) diff --git a/opencodeblocks/graphics/blocks/codeblock.py b/opencodeblocks/graphics/blocks/codeblock.py index 7ede6bb6..ef088285 100644 --- a/opencodeblocks/graphics/blocks/codeblock.py +++ b/opencodeblocks/graphics/blocks/codeblock.py @@ -5,38 +5,59 @@ from typing import Optional -from PyQt5.QtCore import Qt, QByteArray +from PyQt5.QtCore import Qt, QByteArray, QPointF from PyQt5.QtGui import QPainter, QPainterPath, QPixmap -from PyQt5.QtWidgets import QStyleOptionGraphicsItem, QWidget, QGraphicsProxyWidget, QLabel +from PyQt5.QtWidgets import QStyleOptionGraphicsItem, QWidget, QGraphicsProxyWidget, QLabel, \ + QGraphicsSceneMouseEvent, QApplication from opencodeblocks.graphics.blocks.block import OCBBlock from opencodeblocks.graphics.pyeditor import PythonEditor class OCBCodeBlock(OCBBlock): - """ Code Block. """ + """ + Code Block + + Features an area to edit code as well as a panel to display the output. + + The following is always true: + output_panel_height + source_panel_height + edge_size*2 + title_height == height + + """ def __init__(self, **kwargs): super().__init__(block_type='code', **kwargs) + + self.output_panel_height = self.height / 3 + self._min_output_panel_height = 20 + self._min_source_editor_height = 20 + self.source_editor = self.init_source_editor() self.display = self.init_display() self.stdout = "" self.image = "" + self.resizing_source_code = False + + self.update_all() # Set the geometry of display and source_editor + def init_source_editor(self): """ Initialize the python source code editor. """ source_editor_graphics = QGraphicsProxyWidget(self) source_editor = PythonEditor(self) - source_editor.setGeometry( - int(self.edge_size), - int(self.edge_size + self.title_height), - int(self.width - 2*self.edge_size), - int(self.height - self.title_height - 2*self.edge_size) - ) source_editor_graphics.setWidget(source_editor) source_editor_graphics.setZValue(-1) return source_editor_graphics + @property + def _editor_widget_height(self): + return self.height - self.title_height - 2*self.edge_size \ + - self.output_panel_height + + @_editor_widget_height.setter + def _editor_widget_height(self, value: int): + self.output_panel_height = self.height - value - self.title_height - 2*self.edge_size + def update_all(self): """ Update the code block parts. """ if hasattr(self, 'source_editor'): @@ -45,14 +66,14 @@ def update_all(self): int(self.edge_size), int(self.edge_size + self.title_height), int(self._width - 2*self.edge_size), - int(self.height - self.title_height - 2*self.edge_size) + int(self._editor_widget_height) ) display_widget = self.display.widget() display_widget.setGeometry( int(self.edge_size), - int(self.height + self.edge_size), + int(self.height - self.output_panel_height - self.edge_size), int(self.width - 2*self.edge_size), - int(self.height*0.3 - 2*self.edge_size) + int(self.output_panel_height) ) super().update_all() @@ -60,6 +81,7 @@ def update_all(self): def source(self) -> str: """ Source code. """ return self._source + @source.setter def source(self, value:str): self._source = value @@ -69,7 +91,7 @@ def source(self, value:str): @property def stdout(self) -> str: - """ Code output. """ + """ Code output. Be careful, this also includes stderr """ return self._stdout @stdout.setter def stdout(self, value:str): @@ -84,6 +106,7 @@ def stdout(self, value:str): def image(self) -> str: """ Code output. """ return self._image + @image.setter def image(self, value:str): self._image = value @@ -111,23 +134,75 @@ def paint(self, painter: QPainter, super().paint(painter, option, widget) path_title = QPainterPath() path_title.setFillRule(Qt.FillRule.WindingFill) - path_title.addRoundedRect(0, 0, self.width, 1.3*self.height, + path_title.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_title.simplified()) + def _is_in_resize_source_code_area(self, pos:QPointF): + """ + Return True if the given position is in the area + used to resize the source code widget + """ + source_editor_start = self.height - self.output_panel_height - self.edge_size + + return self.width - self.edge_size/2 < pos.x() and \ + source_editor_start - self.edge_size < pos.y() < source_editor_start + self.edge_size + + + def _is_in_resize_area(self, pos:QPointF): + """ Return True if the given position is in the block resize_area. """ + + # This block features 2 resizing areas with 2 different behaviors + is_in_bottom_left = super()._is_in_resize_area(pos) + return is_in_bottom_left or self._is_in_resize_source_code_area(pos) + + def _start_resize(self,pos:QPointF): + self.resizing = True + self.resize_start = pos + if self._is_in_resize_source_code_area(pos): + self.resizing_source_code = True + QApplication.setOverrideCursor(Qt.CursorShape.SizeFDiagCursor) + + def _stop_resize(self): + self.resizing = False + self.resizing_source_code = False + QApplication.restoreOverrideCursor() + + def mouseMoveEvent(self, event:QGraphicsSceneMouseEvent): + """ + We override the default resizing behavior as the code part and the display part of the block + block can be resized independently. + """ + if self.resizing: + delta = event.pos() - self.resize_start + self.width = max(self.width + delta.x(), self._min_width) + + height_delta = max(delta.y(), + # List of all the quantities that must remain negative. + # Mainly: min_height - height must be negative for all elements + self._min_output_panel_height - self.output_panel_height, + self._min_height - self.height, + self._min_source_editor_height - self._editor_widget_height + ) + + self.height += height_delta + if not self.resizing_source_code: + self.output_panel_height += height_delta + + self.resize_start = event.pos() + self.title_graphics.setTextWidth(self.width - 2 * self.edge_size) + self.update() + + self.moved = True + super().mouseMoveEvent(event) + def init_display(self): - """ Initialize the ouptput display widget: QLabel """ + """ Initialize the output display widget: QLabel """ display_graphics = QGraphicsProxyWidget(self) display = QLabel() display.setText("") - display.setGeometry( - int(self.edge_size), - int(self.edge_size + self.height), - int(self.width - 2*self.edge_size), - int(self.height*0.3 - 2*self.edge_size) - ) display_graphics.setWidget(display) display_graphics.setZValue(-1) return display_graphics diff --git a/opencodeblocks/graphics/function_parsing.py b/opencodeblocks/graphics/function_parsing.py index 33d5564a..1b371ec6 100644 --- a/opencodeblocks/graphics/function_parsing.py +++ b/opencodeblocks/graphics/function_parsing.py @@ -22,7 +22,7 @@ def run_cell(cell: str) -> str: def run_with_variable_output(cell: str) -> None: """ - This is a proof of concept to show that it is possible + This is a proof of concept to show that it is possible to collect a variable output from a kernel execution Here the kernel executes the code and prints the output repeatedly @@ -33,11 +33,10 @@ def run_with_variable_output(cell: str) -> None: """ kernel.client.execute(cell) done = False - while done == False: + while not done: output, done = kernel.update_output() print(output) - def get_function_name(code: str) -> str: """ Parses a string of code and returns the first function name it finds diff --git a/opencodeblocks/graphics/kernel.py b/opencodeblocks/graphics/kernel.py index 87ad8e14..5d16ea74 100644 --- a/opencodeblocks/graphics/kernel.py +++ b/opencodeblocks/graphics/kernel.py @@ -19,80 +19,83 @@ def message_to_output(self, message: dict) -> Tuple[str, str]: message: dict representing the a message sent by the kernel Return: - single output found in the message in that order of priority: image > text data > text print > error > nothing - type: 'image' or 'text' + single output found in the message in that order of priority: + image > text data > text print > error > nothing """ - type = 'None' + message_type = 'None' if 'data' in message: if 'image/png' in message['data']: - type = 'image' + message_type = 'image' # output an image (from plt.plot or plt.imshow) out = message['data']['image/png'] else: - type = 'text' + message_type = 'text' # output data as str (for example if code="a=10\na") out = message['data']['text/plain'] elif 'name' in message and message['name'] == "stdout": - type = 'text' + message_type = 'text' # output a print (print("Hello World")) out = message['text'] elif 'traceback' in message: - type = 'text' + message_type = 'text' # output an error out = '\n'.join(message['traceback']) else: - type = 'text' + message_type = 'text' out = '' - return out, type + return out, message_type def execute(self, code: str) -> str: """ - Executes code in the kernel and returns the output of the last message sent by the kernel in return + Executes code in the kernel and returns the output of the last message sent by the kernel Args: - code: str representing a piece of Python code to execute + code: String representing a piece of Python code to execute Return: - output from the last message sent by the kernel in return + output from the last message sent by the kernel """ _ = self.client.execute(code) - io_msg_content = {} - if 'execution_state' in io_msg_content and io_msg_content['execution_state'] == 'idle': - return "no output" - - while True: + done = False + while not done: # Check for messages, break the loop when the kernel stops sending messages - message = io_msg_content - try: - io_msg_content = self.client.get_iopub_msg(timeout=1000)[ - 'content'] - if 'execution_state' in io_msg_content and io_msg_content['execution_state'] == 'idle': - break - except queue.Empty: - break - + new_message, done = self.get_message() + if not done: + message = new_message return self.message_to_output(message)[0] - def update_output(self) -> Tuple[str, str, bool]: + def get_message(self) -> Tuple[str, bool]: """ - Returns the current output of the kernel + Get message in the jupyter kernel + + Args: + code: String representing a piece of Python code to execute Return: - current output of the kernel; done: bool, True if the kernel has no message to send + Tuple of: + - output from the last message sent by the kernel + - boolean repesenting if the kernel as any other message to send. """ - message = None done = False try: - message = self.client.get_iopub_msg(timeout=1000)[ - 'content'] + message = self.client.get_iopub_msg(timeout=1000)['content'] if 'execution_state' in message and message['execution_state'] == 'idle': done = True except queue.Empty: + message = None done = True + return message, done - out, type = self.message_to_output(message) + def update_output(self) -> Tuple[str, str, bool]: + """ + Returns the current output of the kernel - return out, type, done + Return: + current output of the kernel; done: bool, True if the kernel has no message to send + """ + message, done = self.get_message() + out, _ = self.message_to_output(message) + return out, done def __del__(self): """ diff --git a/opencodeblocks/graphics/pyeditor.py b/opencodeblocks/graphics/pyeditor.py index 1e40c510..689a14a1 100644 --- a/opencodeblocks/graphics/pyeditor.py +++ b/opencodeblocks/graphics/pyeditor.py @@ -131,11 +131,11 @@ def focusOutEvent(self, event: QFocusEvent): # Keep the GUI alive QCoreApplication.processEvents() # Save kernel message and display it - output, type, done = kernel.update_output() + output, output_type, done = kernel.update_output() if done is False: - if type == 'text': + if output_type == 'text': self.block.stdout = output - elif type == 'image': + elif output_type == 'image': self.block.image = output return super().focusOutEvent(event) diff --git a/opencodeblocks/graphics/qss/dark_resources.py b/opencodeblocks/graphics/qss/dark_resources.py index 358c0113..3b7d2693 100644 --- a/opencodeblocks/graphics/qss/dark_resources.py +++ b/opencodeblocks/graphics/qss/dark_resources.py @@ -501,9 +501,11 @@ qt_resource_struct = qt_resource_struct_v2 def qInitResources(): - QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, + qt_resource_name, qt_resource_data) def qCleanupResources(): - QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, + qt_resource_name, qt_resource_data) qInitResources() diff --git a/opencodeblocks/graphics/window.py b/opencodeblocks/graphics/window.py index 98923246..c2ea695e 100644 --- a/opencodeblocks/graphics/window.py +++ b/opencodeblocks/graphics/window.py @@ -180,7 +180,7 @@ def updateWindowMenu(self): for i, window in enumerate(windows): child = window.widget() - text = "%d %s" % (i + 1, child.windowTitle()) + text = f"{i + 1} {child.windowTitle()}" if i < 9: text = '&' + text diff --git a/requirements-dev.txt b/requirements-dev.txt index 764c45a9..f18845f0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,4 +4,5 @@ pytest-mock pytest-check pytest-pspec pylint -pylint-pytest \ No newline at end of file +pylint-pytest +autopep8 \ No newline at end of file