0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-14 03:31:50 +02:00

fix(widgets/editor): qscintilla editor removed

This commit is contained in:
2024-04-26 17:57:54 +02:00
parent a22229849c
commit ab8537483d
5 changed files with 0 additions and 582 deletions

View File

@ -1,4 +1,3 @@
from .editor import BECEditor
from .figure import BECFigure, FigureConfig
from .monitor import BECMonitor
from .motor_control import (

View File

@ -1 +0,0 @@
from .editor import BECEditor

View File

@ -1,407 +0,0 @@
import subprocess
import qdarktheme
from jedi import Script
from jedi.api import Completion
from qtconsole.manager import QtKernelManager
from qtconsole.rich_jupyter_widget import RichJupyterWidget
# pylint: disable=no-name-in-module
from qtpy.Qsci import QsciAPIs, QsciLexerPython, QsciScintilla
from qtpy.QtCore import Qt, QThread, Signal
from qtpy.QtGui import QColor, QFont
from qtpy.QtWidgets import QApplication, QFileDialog, QSplitter, QTextEdit, QVBoxLayout, QWidget
from bec_widgets.widgets.toolbar import ModularToolBar
class AutoCompleter(QThread):
"""Initializes the AutoCompleter thread for handling autocompletion and signature help.
Args:
file_path (str): The path to the file for which autocompletion is required.
api (QsciAPIs): The QScintilla API instance used for managing autocompletions.
enable_docstring (bool, optional): Flag to determine if docstrings should be included in the signatures.
"""
def __init__(self, file_path: str, api: QsciAPIs, enable_docstring: bool = False):
super().__init__(None)
self.file_path = file_path
self.script: Script = None
self.api: QsciAPIs = api
self.completions: list[Completion] = None
self.line = 0
self.index = 0
self.text = ""
# TODO so far disabled, quite buggy, docstring extraction has to be generalised
self.enable_docstring = enable_docstring
def update_script(self, text: str):
"""Updates the script for Jedi completion based on the current editor text.
Args:
text (str): The current text of the editor.
"""
if self.script is None or self.script.path != text:
self.script = Script(text, path=self.file_path)
def run(self):
"""Runs the thread for generating autocompletions. Overrides QThread.run."""
self.update_script(self.text)
try:
self.completions = self.script.complete(self.line, self.index)
self.load_autocomplete(self.completions)
except Exception as err:
print(err)
self.finished.emit()
def get_function_signature(self, line: int, index: int, text: str) -> str:
"""Fetches the function signature for a given position in the text.
Args:
line (int): The line number in the editor.
index (int): The index (column number) in the line.
text (str): The current text of the editor.
Returns:
str: A string containing the function signature or an empty string if not available.
"""
self.update_script(text)
try:
signatures = self.script.get_signatures(line, index)
if signatures and self.enable_docstring is True:
full_docstring = signatures[0].docstring(raw=True)
compact_docstring = self.get_compact_docstring(full_docstring)
return compact_docstring
if signatures and self.enable_docstring is False:
return signatures[0].to_string()
except Exception as err:
print(f"Signature Error:{err}")
return ""
def load_autocomplete(self, completions: list):
"""Loads the autocomplete suggestions into the QScintilla API.
Args:
completions (list[Completion]): A list of Completion objects to be added to the API.
"""
self.api.clear()
for i in completions:
self.api.add(i.name)
self.api.prepare()
def get_completions(self, line: int, index: int, text: str):
"""Starts the autocompletion process for a given position in the text.
Args:
line (int): The line number in the editor.
index (int): The index (column number) in the line.
text (str): The current text of the editor.
"""
self.line = line
self.index = index
self.text = text
self.start()
def get_compact_docstring(self, full_docstring):
"""Generates a compact version of a function's docstring.
Args:
full_docstring (str): The full docstring of a function.
Returns:
str: A compact version of the docstring.
"""
lines = full_docstring.split("\n")
# TODO make it also for different docstring styles, now it is only for numpy style
cutoff_indices = [
i
for i, line in enumerate(lines)
if line.strip().lower() in ["parameters", "returns", "examples", "see also", "warnings"]
]
if cutoff_indices:
lines = lines[: cutoff_indices[0]]
compact_docstring = "\n".join(lines).strip()
return compact_docstring
class ScriptRunnerThread(QThread):
"""Initializes the thread for running a Python script.
Args:
script (str): The script to be executed.
"""
outputSignal = Signal(str)
def __init__(self, script):
super().__init__()
self.script = script
def run(self):
"""Executes the script in a subprocess and emits output through a signal. Overrides QThread.run."""
process = subprocess.Popen(
["python", "-u", "-c", self.script],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1,
universal_newlines=True,
text=True,
)
while True:
output = process.stdout.readline()
if output == "" and process.poll() is not None:
break
if output:
self.outputSignal.emit(output)
error = process.communicate()[1]
if error:
self.outputSignal.emit(error)
class BECEditor(QWidget):
"""Initializes the BEC Editor widget.
Args:
toolbar_enabled (bool, optional): Determines if the toolbar should be enabled. Defaults to True.
"""
def __init__(
self, toolbar_enabled=True, jupyter_terminal_enabled=False, docstring_tooltip=False
):
super().__init__()
self.script_runner_thread = None
self.file_path = None
self.docstring_tooltip = docstring_tooltip
self.jupyter_terminal_enabled = jupyter_terminal_enabled
# TODO just temporary solution, could be extended to other languages
self.is_python_file = True
# Initialize the editor and terminal
self.editor = QsciScintilla()
if self.jupyter_terminal_enabled:
self.terminal = self.make_jupyter_widget_with_kernel()
else:
self.terminal = QTextEdit()
self.terminal.setReadOnly(True)
# Layout
self.layout = QVBoxLayout()
# Initialize and add the toolbar if enabled
if toolbar_enabled:
self.toolbar = ModularToolBar(self)
self.layout.addWidget(self.toolbar)
# Initialize the splitter
self.splitter = QSplitter(Qt.Orientation.Vertical, self)
self.splitter.addWidget(self.editor)
self.splitter.addWidget(self.terminal)
self.splitter.setSizes([400, 200])
# Add Splitter to layout
self.layout.addWidget(self.splitter)
self.setLayout(self.layout)
self.setup_editor()
def setup_editor(self):
"""Sets up the editor with necessary configurations like lexer, auto indentation, and line numbers."""
# Set the lexer for Python
self.lexer = QsciLexerPython()
self.editor.setLexer(self.lexer)
# Enable auto indentation and competition within the editor
self.editor.setAutoIndent(True)
self.editor.setIndentationsUseTabs(False)
self.editor.setIndentationWidth(4)
self.editor.setAutoCompletionSource(QsciScintilla.AutoCompletionSource.AcsAll)
self.editor.setAutoCompletionThreshold(1)
# Autocomplete for python file
# Connect cursor position change signal for autocompletion
self.editor.cursorPositionChanged.connect(self.on_cursor_position_changed)
# if self.is_python_file: #TODO can be changed depending on supported languages
self.__api = QsciAPIs(self.lexer)
self.auto_completer = AutoCompleter(
self.editor.text(), self.__api, enable_docstring=self.docstring_tooltip
)
self.auto_completer.finished.connect(self.loaded_autocomplete)
# Enable line numbers in the margin
self.editor.setMarginType(0, QsciScintilla.MarginType.NumberMargin)
self.editor.setMarginWidth(0, "0000") # Adjust the width as needed
# Additional UI elements like menu for load/save can be added here
self.set_editor_style()
@staticmethod
def make_jupyter_widget_with_kernel() -> object:
"""Start a kernel, connect to it, and create a RichJupyterWidget to use it"""
kernel_manager = QtKernelManager(kernel_name="python3")
kernel_manager.start_kernel()
kernel_client = kernel_manager.client()
kernel_client.start_channels()
jupyter_widget = RichJupyterWidget()
jupyter_widget.set_default_style("linux")
jupyter_widget.kernel_manager = kernel_manager
jupyter_widget.kernel_client = kernel_client
return jupyter_widget
def show_call_tip(self, position):
"""Shows a call tip at the given position in the editor.
Args:
position (int): The position in the editor where the call tip should be shown.
"""
line, index = self.editor.lineIndexFromPosition(position)
signature = self.auto_completer.get_function_signature(line + 1, index, self.editor.text())
if signature:
self.editor.showUserList(1, [signature])
def on_cursor_position_changed(self, line, index):
"""Handles the event of cursor position change in the editor.
Args:
line (int): The current line number where the cursor is.
index (int): The current column index where the cursor is.
"""
# if self.is_python_file: #TODO can be changed depending on supported languages
# Get completions
self.auto_completer.get_completions(line + 1, index, self.editor.text())
self.editor.autoCompleteFromAPIs()
# Show call tip - signature
position = self.editor.positionFromLineIndex(line, index)
self.show_call_tip(position)
def loaded_autocomplete(self):
"""Placeholder method for actions after autocompletion data is loaded."""
def set_editor_style(self):
"""Sets the style and color scheme for the editor."""
# Dracula Theme Colors
background_color = QColor("#282a36")
text_color = QColor("#f8f8f2")
keyword_color = QColor("#8be9fd")
string_color = QColor("#f1fa8c")
comment_color = QColor("#6272a4")
class_function_color = QColor("#50fa7b")
# Set Font
font = QFont()
font.setFamily("Consolas")
font.setPointSize(10)
self.editor.setFont(font)
self.editor.setMarginsFont(font)
# Set Editor Colors
self.editor.setMarginsBackgroundColor(background_color)
self.editor.setMarginsForegroundColor(text_color)
self.editor.setCaretForegroundColor(text_color)
self.editor.setCaretLineBackgroundColor(QColor("#44475a"))
self.editor.setPaper(background_color) # Set the background color for the entire paper
self.editor.setColor(text_color)
# Set editor
# Syntax Highlighting Colors
lexer = self.editor.lexer()
if lexer:
lexer.setDefaultPaper(background_color) # Set the background color for the text area
lexer.setDefaultColor(text_color)
lexer.setColor(keyword_color, QsciLexerPython.Keyword)
lexer.setColor(string_color, QsciLexerPython.DoubleQuotedString)
lexer.setColor(string_color, QsciLexerPython.SingleQuotedString)
lexer.setColor(comment_color, QsciLexerPython.Comment)
lexer.setColor(class_function_color, QsciLexerPython.ClassName)
lexer.setColor(class_function_color, QsciLexerPython.FunctionMethodName)
# Set the style for all text to have a transparent background
# TODO find better way how to do it!
for style in range(
128
): # QsciScintilla supports 128 styles by default, this set all to transparent background
self.lexer.setPaper(background_color, style)
def run_script(self):
"""Runs the current script in the editor."""
if self.jupyter_terminal_enabled:
script = self.editor.text()
self.terminal.execute(script)
else:
script = self.editor.text()
self.script_runner_thread = ScriptRunnerThread(script)
self.script_runner_thread.outputSignal.connect(self.update_terminal)
self.script_runner_thread.start()
def update_terminal(self, text):
"""Updates the terminal with new text.
Args:
text (str): The text to be appended to the terminal.
"""
self.terminal.append(text)
def enable_docstring_tooltip(self):
"""Enables the docstring tooltip."""
self.docstring_tooltip = True
self.auto_completer.enable_docstring = True
def open_file(self):
"""Opens a file dialog for selecting and opening a Python file in the editor."""
options = QFileDialog.Options()
options |= QFileDialog.DontUseNativeDialog
file_path, _ = QFileDialog.getOpenFileName(
self, "Open file", "", "Python files (*.py);;All Files (*)", options=options
)
if not file_path:
return
try:
with open(file_path, "r") as file:
text = file.read()
self.editor.setText(text)
except FileNotFoundError:
print(f"The file {file_path} was not found.")
except Exception as e:
print(f"An error occurred while opening the file {file_path}: {e}")
def save_file(self):
"""Opens a save file dialog for saving the current script in the editor."""
options = QFileDialog.Options()
options |= QFileDialog.DontUseNativeDialog
file_path, _ = QFileDialog.getSaveFileName(
self, "Save file", "", "Python files (*.py);;All Files (*)", options=options
)
if not file_path:
return
try:
if not file_path.endswith(".py"):
file_path += ".py"
with open(file_path, "w") as file:
text = self.editor.text()
file.write(text)
print(f"File saved to {file_path}")
except Exception as e:
print(f"An error occurred while saving the file to {file_path}: {e}")
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
qdarktheme.setup_theme("auto")
mainWin = BECEditor(jupyter_terminal_enabled=True)
mainWin.show()
app.exec()

View File

@ -5,7 +5,6 @@ __version__ = "0.49.0"
# Default to PyQt6 if no other Qt binding is installed
QT_DEPENDENCY = "PyQt6<=6.6.3"
QSCINTILLA_DEPENDENCY = "PyQt6-QScintilla"
# pylint: disable=unused-import
try:
@ -14,7 +13,6 @@ except ImportError:
pass
else:
QT_DEPENDENCY = "PyQt5>=5.9"
QSCINTILLA_DEPENDENCY = "QScintilla"
if __name__ == "__main__":
setup(
@ -23,7 +21,6 @@ if __name__ == "__main__":
"qtconsole",
"PyQt6-Qt6<=6.6.3",
QT_DEPENDENCY,
QSCINTILLA_DEPENDENCY,
"jedi",
"qtpy",
"pyqtgraph",

View File

@ -1,170 +0,0 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import os
import tempfile
from unittest.mock import MagicMock, mock_open, patch
import pytest
from qtpy.Qsci import QsciScintilla
from qtpy.QtWidgets import QTextEdit
from bec_widgets.widgets.editor.editor import AutoCompleter, BECEditor
@pytest.fixture(scope="function")
def editor(qtbot, docstring_tooltip=False):
"""Helper function to set up the BECEditor widget."""
widget = BECEditor(toolbar_enabled=True, docstring_tooltip=docstring_tooltip)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def find_action_by_text(toolbar, text):
"""Helper function to find an action in the toolbar by its text."""
for action in toolbar.actions():
if action.text() == text:
return action
return None
def test_bec_editor_initialization(editor):
"""Test if the BECEditor widget is initialized correctly."""
assert isinstance(editor.editor, QsciScintilla)
assert isinstance(editor.terminal, QTextEdit)
assert isinstance(editor.auto_completer, AutoCompleter)
@patch("bec_widgets.widgets.editor.editor.Script") # Mock the Script class from jedi
def test_autocompleter_suggestions(mock_script, editor, qtbot):
"""Test if the autocompleter provides correct suggestions based on input."""
# Set up mock return values for the Script.complete method
mock_completion = MagicMock()
mock_completion.name = "mocked_method"
mock_script.return_value.complete.return_value = [mock_completion]
# Simulate user input in the editor
test_code = "print("
editor.editor.setText(test_code)
line, index = editor.editor.getCursorPosition()
# Trigger autocomplete
editor.auto_completer.get_completions(line, index, test_code)
# Use qtbot to wait for the completion thread
qtbot.waitUntil(lambda: editor.auto_completer.completions is not None, timeout=1000)
# Check if the expected completion is in the autocompleter's suggestions
suggested_methods = [completion.name for completion in editor.auto_completer.completions]
assert "mocked_method" in suggested_methods
@patch("bec_widgets.widgets.editor.editor.Script") # Mock the Script class from jedi
@pytest.mark.parametrize(
"docstring_enabled, expected_signature",
[(True, "Mocked signature with docstring"), (False, "Mocked signature")],
)
def test_autocompleter_signature(mock_script, editor, docstring_enabled, expected_signature):
"""Test if the autocompleter provides correct function signature based on docstring setting."""
# Set docstring mode based on parameter
editor.docstring_tooltip = docstring_enabled
editor.auto_completer.enable_docstring = docstring_enabled
# Set up mock return values for the Script.get_signatures method
mock_signature = MagicMock()
if docstring_enabled:
mock_signature.docstring.return_value = expected_signature
else:
mock_signature.to_string.return_value = expected_signature
mock_script.return_value.get_signatures.return_value = [mock_signature]
# Simulate user input that would trigger a signature request
test_code = "print("
editor.editor.setText(test_code)
line, index = editor.editor.getCursorPosition()
# Trigger signature request
signature = editor.auto_completer.get_function_signature(line, index, test_code)
# Check if the expected signature is returned
assert signature == expected_signature
def test_open_file(editor):
"""Test open_file method of BECEditor."""
# Create a temporary file with some content
with tempfile.NamedTemporaryFile(delete=False, suffix=".py") as temp_file:
temp_file.write(b"test file content")
# Mock user selecting the file in the dialog
with patch("qtpy.QtWidgets.QFileDialog.getOpenFileName", return_value=(temp_file.name, "")):
with patch("builtins.open", new_callable=mock_open, read_data="test file content"):
editor.open_file()
# Verify if the editor's text is set to the file content
assert editor.editor.text() == "test file content"
# Clean up by removing the temporary file
os.remove(temp_file.name)
def test_save_file(editor):
"""Test save_file method of BECEditor."""
# Set some text in the editor
editor.editor.setText("test save content")
# Mock user selecting the file in the dialog
with patch(
"qtpy.QtWidgets.QFileDialog.getSaveFileName", return_value=("/path/to/save/file.py", "")
):
with patch("builtins.open", new_callable=mock_open) as mock_file:
editor.save_file()
# Verify if the file was opened correctly for writing
mock_file.assert_called_with("/path/to/save/file.py", "w")
# Verify if the editor's text was written to the file
mock_file().write.assert_called_with("test save content")
def test_open_file_through_toolbar(editor):
"""Test the open_file method through the ModularToolBar."""
# Create a temporary file
with tempfile.NamedTemporaryFile(delete=False, suffix=".py") as temp_file:
temp_file.write(b"test file content")
# Find the open file action in the toolbar
open_action = find_action_by_text(editor.toolbar, "Open File")
assert open_action is not None, "Open File action should be found"
# Mock the file dialog and built-in open function
with patch("qtpy.QtWidgets.QFileDialog.getOpenFileName", return_value=(temp_file.name, "")):
with patch("builtins.open", new_callable=mock_open, read_data="test file content"):
open_action.trigger()
# Verify if the editor's text is set to the file content
assert editor.editor.text() == "test file content"
# Clean up
os.remove(temp_file.name)
def test_save_file_through_toolbar(editor):
"""Test the save_file method through the ModularToolBar."""
# Set some text in the editor
editor.editor.setText("test save content")
# Find the save file action in the toolbar
save_action = find_action_by_text(editor.toolbar, "Save File")
assert save_action is not None, "Save File action should be found"
# Mock the file dialog and built-in open function
with patch(
"qtpy.QtWidgets.QFileDialog.getSaveFileName", return_value=("/path/to/save/file.py", "")
):
with patch("builtins.open", new_callable=mock_open) as mock_file:
save_action.trigger()
# Verify if the file was opened correctly for writing
mock_file.assert_called_with("/path/to/save/file.py", "w")
# Verify if the editor's text was written to the file
mock_file().write.assert_called_with("test save content")