Source code for hyloa.gui.script_window

# This file is part of HYLOA - HYsteresis LOop Analyzer.
# Copyright (C) 2024 Francesco Zeno Costanzo

# HYLOA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# HYLOA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.

# You should have received a copy of the GNU General Public License
# along with HYLOA. If not, see <https://www.gnu.org/licenses/>.


"""
Code for manage script's window
"""
import io
import sys
import numpy as np

from PyQt5.QtCore import QRegExp, QSize, Qt, QRect
from PyQt5.QtGui import (
    QSyntaxHighlighter, QTextCharFormat, QColor, QFont, QPainter,
    QTextFormat, QKeySequence
)
from PyQt5.QtWidgets import ( 
    QWidget, QVBoxLayout, QPushButton, QHBoxLayout,
    QPlainTextEdit, QFileDialog, QMessageBox, QTextEdit,
    QAction
)


[docs] class ScriptEditor(QPlainTextEdit): ''' Class to handle the scripting window ''' def __init__(self, app_instance): ''' Parameters ---------- app_instance : MainApp Main application instance containing the session data. ''' super().__init__() self.app_instance = app_instance self.line_number_area = LineNumberArea(self) self.setFont(QFont("Courier", 10)) self.setPlaceholderText("# Write your Python script here...") self.setLineWrapMode(QPlainTextEdit.NoWrap) self.highlighter = PythonHighlighter(self.document()) self.blockCountChanged.connect(self.update_line_number_area_width) self.updateRequest.connect(self.update_line_number_area) self.cursorPositionChanged.connect(self.highlight_current_line) self.update_line_number_area_width(0) self.highlight_current_line() # Layout (buttons) self.window = QWidget() self.window.setWindowTitle("Python Script Editor") layout = QVBoxLayout(self.window) layout.addWidget(self) # Shortcut for saving self.save_action = QAction("Save", self) self.save_action.setShortcut(QKeySequence.Save) self.save_action.triggered.connect(self.save_script) self.addAction(self.save_action) # Shortuct for running self.run_action = QAction("Run", self) self.run_action.setShortcut(QKeySequence(Qt.CTRL + Qt.Key_Return)) self.run_action.triggered.connect(self.run_script) self.addAction(self.run_action) button_layout = QHBoxLayout() for text, slot in [ ("Run Script", self.run_script), ("Save Script", self.save_script), ("Load Script", self.load_script) ]: btn = QPushButton(text) btn.clicked.connect(slot) button_layout.addWidget(btn) layout.addLayout(button_layout) self.window.setLayout(layout)
[docs] def run_script(self): ''' Function to run the script ''' script_text = self.toPlainText() # Find the shell of the app shell = self.app_instance.shell_widget if hasattr(self.app_instance, 'shell_widget') else None if not shell: QMessageBox.critical(self, "Error", "Shell not found.") return # Redirection of standard output and error old_stdout, old_stderr = sys.stdout, sys.stderr sys.stdout = output_capture = io.StringIO() sys.stderr = output_capture try: exec(script_text, globals(), shell.local_vars) except Exception as e: output_capture.write(f"\nError: {str(e)}\n") finally: sys.stdout = old_stdout sys.stderr = old_stderr for idx, df in enumerate(self.app_instance.dataframes): for column in df.columns: if column in shell.local_vars: modified_array = shell.local_vars[column] if not np.array_equal(df[column].values, modified_array): df[column] = modified_array output = output_capture.getvalue() if output.strip(): # Write over the prompt text = shell.shell_text.toPlainText() last_prompt_index = text.rfind(">>> ") if last_prompt_index != -1: # Take all except the prompt before_prompt = text[:last_prompt_index] # Build the final output new_text = before_prompt + output + "\n>>> " # Overwrite in the shell shell.shell_text.setPlainText(new_text) shell.shell_text.moveCursor(shell.shell_text.textCursor().End)
[docs] def save_script(self): ''' Function for save the script ''' path, _ = QFileDialog.getSaveFileName( self, "Salva Script", "", "Python Files (*.py)", options=QFileDialog.Options() ) if path: if not path.endswith(".py"): path += ".py" try: with open(path, 'w') as f: f.write(self.toPlainText()) QMessageBox.information(self, "Salvato", f"Script saved in:\n{path}") except Exception as e: QMessageBox.critical(self, "Error", str(e))
[docs] def load_script(self): ''' Function to load a pre written script ''' path, _ = QFileDialog.getOpenFileName(self, "Load Script", "", "Python Files (*.py)") if path: try: with open(path, 'r') as f: self.setPlainText(f.read()) except Exception as e: QMessageBox.critical(self, "Errore", str(e))
[docs] def line_number_area_width(self): ''' Calculate the width required for the line number area. Returns ------- space : int The width in pixels needed to display the line numbers based on the current number of text blocks (lines). ''' digits = len(str(self.blockCount())) space = 3 + self.fontMetrics().width('9') * digits return space
[docs] def update_line_number_area_width(self, _): ''' Update the viewport margins to account for the line number area width. Parameters ---------- _ : Any Unused parameter, often a placeholder for a signal. ''' self.setViewportMargins(self.line_number_area_width(), 0, 0, 0)
[docs] def update_line_number_area(self, rect, dy): ''' Update the display of the line number area when the editor is scrolled or updated. Parameters ---------- rect : QRect The region of the editor that needs updating. dy : int The number of pixels the view was vertically scrolled by ''' if dy: self.line_number_area.scroll(0, dy) else: self.line_number_area.update(0, rect.y(), self.line_number_area.width(), rect.height()) if rect.contains(self.viewport().rect()): self.update_line_number_area_width(0)
[docs] def resizeEvent(self, event): ''' Handle resize events and reposition the line number area accordingly. Parameters ---------- event : QResizeEvent The event triggered when the editor is resized. ''' super().resizeEvent(event) cr = self.contentsRect() self.line_number_area.setGeometry( QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height()) )
[docs] def line_number_area_paint(self, event): ''' Paint the line number area. Parameters ---------- event : QPaintEvent The paint event containing the area to be redrawn. Notes ----- This method paints line numbers aligned to the right of the line number area, highlighting only visible blocks. ''' painter = QPainter(self.line_number_area) painter.fillRect(event.rect(), QColor("#f0f0f0")) block = self.firstVisibleBlock() block_number = block.blockNumber() top = int(self.blockBoundingGeometry(block).translated(self.contentOffset()).top()) bottom = top + int(self.blockBoundingRect(block).height()) while block.isValid() and top <= event.rect().bottom(): if block.isVisible() and bottom >= event.rect().top(): number = str(block_number + 1) painter.setPen(Qt.black) painter.drawText(0, top, self.line_number_area.width(), self.fontMetrics().height(), Qt.AlignRight, number) block = block.next() top = bottom bottom = top + int(self.blockBoundingRect(block).height()) block_number += 1
[docs] def highlight_current_line(self): ''' Highlight the background of the current line where the cursor is. The color automatically adapts to the current palette, choosing a light or dark translucent shade depending on whether the application is using a light or dark theme. ''' extra_selections = [] if not self.isReadOnly(): selection = QTextEdit.ExtraSelection() base_color = self.palette().color(self.backgroundRole()) if base_color.lightness() < 128: line_color = QColor(255, 255, 255, 40) else: line_color = QColor(0, 0, 0, 25) selection.format.setBackground(line_color) selection.format.setProperty(QTextFormat.FullWidthSelection, True) selection.cursor = self.textCursor() selection.cursor.clearSelection() extra_selections.append(selection) self.setExtraSelections(extra_selections)
[docs] class PythonHighlighter(QSyntaxHighlighter): ''' Class to adorn script window with highliter ''' def __init__(self, document): ''' Initializes the syntax highlighter with Python-specific highlighting rules. Defines formatting for keywords, strings, single-line comments, and multi-line string blocks using regular expressions and `QTextCharFormat`. The highlighter supports highlighting of Python constructs such as keywords (`def`, `class`, etc.), quoted strings, `#` comments, and multi-line string literals using triple quotes. Parameters ---------- document : QTextDocument The text document to which the syntax highlighting will be applied. ''' super().__init__(document) keyword_format = QTextCharFormat() keyword_format.setForeground(QColor("blue")) keyword_format.setFontWeight(QFont.Bold) keyword_patterns = [ r"\bdef\b", r"\bclass\b", r"\breturn\b", r"\bif\b", r"\belse\b", r"\belif\b", r"\bwhile\b", r"\bfor\b", r"\bin\b", r"\bimport\b", r"\bfrom\b", r"\bas\b", r"\bpass\b", r"\bNone\b", r"\bTrue\b", r"\bFalse\b", r"\band\b", r"\bnot\b", r"\bor\b", r"\bwith\b", r"\btry\b", r"\bexcept\b", r"\bfinally\b", r"\blambda\b", ] self.highlighting_rules = [(QRegExp(pattern), keyword_format) for pattern in keyword_patterns] # String format string_format = QTextCharFormat() string_format.setForeground(QColor("darkGreen")) self.highlighting_rules.append((QRegExp(r'"[^"]*"'), string_format)) self.highlighting_rules.append((QRegExp(r"'[^']*'"), string_format)) # Comment format comment_format = QTextCharFormat() comment_format.setForeground(QColor("darkGray")) comment_format.setFontItalic(True) self.highlighting_rules.append((QRegExp(r"#.*"), comment_format)) #self.triple_single = QRegExp(r"\'\'\'") self.triple_double = QRegExp(r'"""') self.multi_line_format = QTextCharFormat() self.multi_line_format.setForeground(QColor("darkGray")) self.multi_line_format.setFontItalic(True)
[docs] def highlightBlock(self, text): ''' Applies syntax highlighting to a single block of text. This method highlights all matching patterns defined in `self.highlighting_rules` as well as multi-line string blocks (e.g., delimited by triple single or double quotes). It is intended to be called automatically by the QSyntaxHighlighter for each block of text. Parameters ---------- text : str The block of text to be highlighted. ''' # Simple highlighting for pattern, fmt in self.highlighting_rules: index = pattern.indexIn(text) while index >= 0: length = pattern.matchedLength() self.setFormat(index, length, fmt) index = pattern.indexIn(text, index + length) # Multi-line highlighting '''...''' self.setCurrentBlockState(0) #self.highlight_multiline(text, self.triple_single, self.triple_single, self.multi_line_format) self.highlight_multiline(text, self.triple_double, self.triple_double, self.multi_line_format)
[docs] def highlight_multiline(self, text, start_expr, end_expr, format): ''' Applies formatting to multi-line text sections matching given delimiters. Highlights text between `start_expr` and `end_expr`, which can span multiple blocks (e.g., for triple-quoted strings). If the end delimiter is not found in the current block, the state is set to continue highlighting in the next block. Parameters ---------- text : str The current block of text to search for multi-line expressions. start_expr : QRegExp Regular expression defining the start of the multi-line section. end_expr : QRegExp Regular expression defining the end of the multi-line section. format : QTextCharFormat The text format to apply to the matched multi-line section. ''' # Previous state: 1 = inside a multi-line block if self.previousBlockState() == 1: start_index = 0 else: start_index = start_expr.indexIn(text) while start_index >= 0: if self.previousBlockState() == 1: end_index = end_expr.indexIn(text) if end_index >= 0: end_index += end_expr.matchedLength() self.setCurrentBlockState(0) else: end_index = len(text) self.setCurrentBlockState(1) else: end_index = end_expr.indexIn(text, start_index + start_expr.matchedLength()) if end_index >= 0: end_index += end_expr.matchedLength() self.setCurrentBlockState(0) else: self.setCurrentBlockState(1) end_index = len(text) length = end_index - start_index self.setFormat(start_index, length, format) # Find another block in the same row if self.currentBlockState() == 1: break start_index = start_expr.indexIn(text, end_index)
[docs] class LineNumberArea(QWidget): """ A QWidget that displays line numbers for a code editor. This widget is used to provide a visual line number area on the left. """ def __init__(self, editor): ''' Initialize the line number area. Parameters ---------- editor : QWidget The code editor widget to which this line number area is attached. ''' super().__init__(editor) self.code_editor = editor
[docs] def sizeHint(self): ''' Return the recommended size for the line number area. Returns ------- QSize The preferred width calculated by the code editor and a height of 0. ''' return QSize(self.code_editor.line_number_area_width(), 0)
[docs] def paintEvent(self, event): ''' Paint the contents of the line number area. Parameters ---------- event : QPaintEvent The paint event containing the region to be updated. Notes ----- This method delegates the painting logic to the parent code editor's `line_number_area_paint` method. ''' self.code_editor.line_number_area_paint(event)