diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py index 58dff3101ab..4053395af0a 100644 --- a/Orange/widgets/data/owpythonscript.py +++ b/Orange/widgets/data/owpythonscript.py @@ -1,27 +1,28 @@ import sys import os import code -import keyword import itertools +import tokenize import unicodedata -import weakref -from functools import reduce from unittest.mock import patch from typing import Optional, List, Dict, Any, TYPE_CHECKING +import pygments.style +from pygments.token import Comment, Keyword, Number, String, Punctuation, Operator, Error, Name +from qtconsole.pygments_highlighter import PygmentsHighlighter + from AnyQt.QtWidgets import ( QPlainTextEdit, QListView, QSizePolicy, QMenu, QSplitter, QLineEdit, QAction, QToolButton, QFileDialog, QStyledItemDelegate, - QStyleOptionViewItem, QPlainTextDocumentLayout -) + QStyleOptionViewItem, QPlainTextDocumentLayout, + QLabel, QWidget, QHBoxLayout, QApplication) from AnyQt.QtGui import ( - QColor, QBrush, QPalette, QFont, QTextDocument, - QSyntaxHighlighter, QTextCharFormat, QTextCursor, QKeySequence, + QColor, QBrush, QPalette, QFont, QTextDocument, QTextCharFormat, + QTextCursor, QKeySequence, QFontMetrics, QPainter ) from AnyQt.QtCore import ( - Qt, QRegularExpression, QByteArray, QItemSelectionModel, QSize, - QMimeDatabase + Qt, QByteArray, QItemSelectionModel, QSize, QRectF, QMimeDatabase, ) from orangewidget.workflow.drophandler import SingleFileDropHandler @@ -30,6 +31,7 @@ from Orange.base import Learner, Model from Orange.util import interleave from Orange.widgets import gui +from Orange.widgets.data.utils.pythoneditor.editor import PythonEditor from Orange.widgets.utils import itemmodels from Orange.widgets.settings import Setting from Orange.widgets.utils.widgetpreview import WidgetPreview @@ -69,130 +71,229 @@ def read_file_content(filename, limit=None): return None -class PythonSyntaxHighlighter(QSyntaxHighlighter): - def __init__(self, parent=None): +# pylint: disable=pointless-string-statement +""" +Adapted from jupyter notebook, which was adapted from GitHub. - self.keywordFormat = text_format(Qt.blue, QFont.Bold) - self.stringFormat = text_format(Qt.darkGreen) - self.defFormat = text_format(Qt.black, QFont.Bold) - self.commentFormat = text_format(Qt.lightGray) - self.decoratorFormat = text_format(Qt.darkGray) +Highlighting styles are applied with pygments. - self.keywords = list(keyword.kwlist) +pygments does not support partial highlighting; on every character +typed, it performs a full pass of the code. If performance is ever +an issue, revert to prior commit, which uses Qutepart's syntax +highlighting implementation. +""" +SYNTAX_HIGHLIGHTING_STYLES = { + 'Light': { + Punctuation: "#000", + Error: '#f00', - self.rules = [(QRegularExpression(r"\b%s\b" % kwd), self.keywordFormat) - for kwd in self.keywords] + \ - [(QRegularExpression(r"\bdef\s+([A-Za-z_]+[A-Za-z0-9_]+)\s*\("), - self.defFormat), - (QRegularExpression(r"\bclass\s+([A-Za-z_]+[A-Za-z0-9_]+)\s*\("), - self.defFormat), - (QRegularExpression(r"'.*'"), self.stringFormat), - (QRegularExpression(r'".*"'), self.stringFormat), - (QRegularExpression(r"#.*"), self.commentFormat), - (QRegularExpression(r"@[A-Za-z_]+[A-Za-z0-9_]+"), - self.decoratorFormat)] + Keyword: 'bold #008000', - self.multilineStart = QRegularExpression(r"(''')|" + r'(""")') - self.multilineEnd = QRegularExpression(r"(''')|" + r'(""")') + Name: '#212121', + Name.Function: '#00f', + Name.Variable: '#05a', + Name.Decorator: '#aa22ff', + Name.Builtin: '#008000', + Name.Builtin.Pseudo: '#05a', - super().__init__(parent) + String: '#ba2121', - def highlightBlock(self, text): - for pattern, fmt in self.rules: - exp = QRegularExpression(pattern) - match = exp.match(text) - index = match.capturedStart() - while index >= 0: - if match.capturedStart(1) > 0: - self.setFormat(match.capturedStart(1), - match.capturedLength(1), fmt) - else: - self.setFormat(match.capturedStart(0), - match.capturedLength(0), fmt) - match = exp.match(text, index + match.capturedLength()) - index = match.capturedStart() - - # Multi line strings - start = self.multilineStart - end = self.multilineEnd - - self.setCurrentBlockState(0) - startIndex, skip = 0, 0 - if self.previousBlockState() != 1: - startIndex, skip = start.match(text).capturedStart(), 3 - while startIndex >= 0: - endIndex = end.match(text, startIndex + skip).capturedStart() - if endIndex == -1: - self.setCurrentBlockState(1) - commentLen = len(text) - startIndex - else: - commentLen = endIndex - startIndex + 3 - self.setFormat(startIndex, commentLen, self.stringFormat) - startIndex, skip = ( - start.match(text, startIndex + commentLen + 3).capturedStart(), - 3 - ) + Number: '#080', + Operator: 'bold #aa22ff', + Operator.Word: 'bold #008000', -class PythonScriptEditor(QPlainTextEdit): - INDENT = 4 + Comment: 'italic #408080', + }, + 'Dark': { + Punctuation: "#fff", + Error: '#f00', - def __init__(self, widget): - super().__init__() - self.widget = widget + Keyword: 'bold #4caf50', - def lastLine(self): - text = str(self.toPlainText()) - pos = self.textCursor().position() - index = text.rfind("\n", 0, pos) - text = text[index: pos].lstrip("\n") - return text + Name: '#e0e0e0', + Name.Function: '#1e88e5', + Name.Variable: '#42a5f5', + Name.Decorator: '#aa22ff', + Name.Builtin: '#43a047', + Name.Builtin.Pseudo: '#42a5f5', - def keyPressEvent(self, event): - if event.key() == Qt.Key_Return: - if event.modifiers() & ( - Qt.ShiftModifier | Qt.ControlModifier | Qt.MetaModifier): - self.widget.commit() - return - text = self.lastLine() - indent = len(text) - len(text.lstrip()) - if text.strip() == "pass" or text.strip().startswith("return "): - indent = max(0, indent - self.INDENT) - elif text.strip().endswith(":"): - indent += self.INDENT - super().keyPressEvent(event) - self.insertPlainText(" " * indent) - elif event.key() == Qt.Key_Tab: - self.insertPlainText(" " * self.INDENT) - elif event.key() == Qt.Key_Backspace: - text = self.lastLine() - if text and not text.strip(): - cursor = self.textCursor() - for _ in range(min(self.INDENT, len(text))): - cursor.deletePreviousChar() - else: - super().keyPressEvent(event) + String: '#ff7070', - else: - super().keyPressEvent(event) + Number: '#66bb6a', - def insertFromMimeData(self, source): - """ - Reimplemented from QPlainTextEdit.insertFromMimeData. - """ - urls = source.urls() - if urls: - self.pasteFile(urls[0]) - else: - super().insertFromMimeData(source) + Operator: 'bold #aa22ff', + Operator.Word: 'bold #4caf50', - def pasteFile(self, url): - new = read_file_content(url.toLocalFile()) - if new: - # inserting text like this allows undo - cursor = QTextCursor(self.document()) - cursor.select(QTextCursor.Document) - cursor.insertText(new) + Comment: 'italic #408080', + } +} + + +def make_pygments_style(scheme_name): + """ + Dynamically create a PygmentsStyle class, + given the name of one of the above highlighting schemes. + """ + return type( + 'PygmentsStyle', + (pygments.style.Style,), + {'styles': SYNTAX_HIGHLIGHTING_STYLES[scheme_name]} + ) + + +class FakeSignatureMixin: + def __init__(self, parent, highlighting_scheme, font): + super().__init__(parent) + self.highlighting_scheme = highlighting_scheme + self.setFont(font) + self.bold_font = QFont(font) + self.bold_font.setBold(True) + + self.indentation_level = 0 + + self._char_4_width = QFontMetrics(font).horizontalAdvance('4444') + + def setIndent(self, margins_width): + self.setContentsMargins(max(0, + round(margins_width) + + (self.indentation_level - 1 * self._char_4_width)), + 0, 0, 0) + + +class FunctionSignature(FakeSignatureMixin, QLabel): + def __init__(self, parent, highlighting_scheme, font, function_name="python_script"): + super().__init__(parent, highlighting_scheme, font) + self.signal_prefix = 'in_' + + # `def python_script(` + self.prefix = ('def ' + '' + function_name + '' + '(') + + # `):` + self.affix = ('):') + + self.update_signal_text({}) + + def update_signal_text(self, signal_values_lengths): + if not self.signal_prefix: + return + lbl_text = self.prefix + if len(signal_values_lengths) > 0: + for name, value in signal_values_lengths.items(): + if value == 1: + lbl_text += self.signal_prefix + name + ', ' + elif value > 1: + lbl_text += self.signal_prefix + name + 's, ' + lbl_text = lbl_text[:-2] # shave off the trailing ', ' + lbl_text += self.affix + if self.text() != lbl_text: + self.setText(lbl_text) + self.update() + + +class ReturnStatement(FakeSignatureMixin, QWidget): + def __init__(self, parent, highlighting_scheme, font): + super().__init__(parent, highlighting_scheme, font) + + self.indentation_level = 1 + self.signal_labels = {} + self._prefix = None + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # `return ` + ret_lbl = QLabel('return ', self) + ret_lbl.setFont(self.font()) + ret_lbl.setContentsMargins(0, 0, 0, 0) + layout.addWidget(ret_lbl) + + # `out_data[, ]` * 4 + self.make_signal_labels('out_') + + layout.addStretch() + self.setLayout(layout) + + def make_signal_labels(self, prefix): + self._prefix = prefix + # `in_data[, ]` + for i, signal in enumerate(OWPythonScript.signal_names): + # adding an empty b tag like this adjusts the + # line height to match the rest of the labels + signal_display_name = signal + signal_lbl = QLabel('' + prefix + signal_display_name, self) + signal_lbl.setFont(self.font()) + signal_lbl.setContentsMargins(0, 0, 0, 0) + self.layout().addWidget(signal_lbl) + + self.signal_labels[signal] = signal_lbl + + if i >= len(OWPythonScript.signal_names) - 1: + break + + comma_lbl = QLabel(', ') + comma_lbl.setFont(self.font()) + comma_lbl.setContentsMargins(0, 0, 0, 0) + comma_lbl.setStyleSheet('.QLabel { color: ' + + self.highlighting_scheme[Punctuation].split(' ')[-1] + + '; }') + self.layout().addWidget(comma_lbl) + + def update_signal_text(self, signal_name, values_length): + if not self._prefix: + return + lbl = self.signal_labels[signal_name] + if values_length == 0: + text = '' + self._prefix + signal_name + else: # if values_length == 1: + text = '' + self._prefix + signal_name + '' + if lbl.text() != text: + lbl.setText(text) + lbl.update() + + +class VimIndicator(QWidget): + def __init__(self, parent): + super().__init__(parent) + self.indicator_color = QColor('#33cc33') + self.indicator_text = 'normal' + + def paintEvent(self, event): + super().paintEvent(event) + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + p.setBrush(self.indicator_color) + + p.save() + p.setPen(Qt.NoPen) + fm = QFontMetrics(self.font()) + width = self.rect().width() + height = fm.height() + 6 + rect = QRectF(0, 0, width, height) + p.drawRoundedRect(rect, 5, 5) + p.restore() + + textstart = (width - fm.width(self.indicator_text)) / 2 + p.drawText(textstart, height / 2 + 5, self.indicator_text) + + def minimumSizeHint(self): + fm = QFontMetrics(self.font()) + width = round(fm.width(self.indicator_text)) + 10 + height = fm.height() + 6 + return QSize(width, height) class PythonConsole(QPlainTextEdit, code.InteractiveConsole): @@ -422,7 +523,7 @@ class OWPythonScript(OWWidget): description = "Write a Python script and run it on input data or models." icon = "icons/PythonScript.svg" priority = 3150 - keywords = ["file", "program", "function"] + keywords = ["program", "function"] class Inputs: data = Input("Data", Table, replaces=["in_data"], @@ -452,35 +553,123 @@ class Outputs: scriptText: Optional[str] = Setting(None, schema_only=True) splitterState: Optional[bytes] = Setting(None) - # Widgets in the same schema share namespace through a dictionary whose - # key is self.signalManager. ales-erjavec expressed concern (and I fully - # agree!) about widget being aware of the outside world. I am leaving this - # anyway. If this causes any problems in the future, replace this with - # shared_namespaces = {} and thus use a common namespace for all instances - # of # PythonScript even if they are in different schemata. - shared_namespaces = weakref.WeakKeyDictionary() + vimModeEnabled = Setting(False) class Error(OWWidget.Error): pass def __init__(self): super().__init__() - self.libraryListSource = [] for name in self.signal_names: setattr(self, name, {}) - self._cachedDocuments = {} + self.splitCanvas = QSplitter(Qt.Vertical, self.mainArea) + self.mainArea.layout().addWidget(self.splitCanvas) + + # Styling - self.infoBox = gui.vBox(self.controlArea, 'Info') - gui.label( - self.infoBox, self, - "

Execute python script.

Input variables:

Output variables:

" + self.defaultFont = defaultFont = ( + 'Menlo' if sys.platform == 'darwin' else + 'Courier' if sys.platform in ['win32', 'cygwin'] else + 'DejaVu Sans Mono' ) + self.defaultFontSize = defaultFontSize = 13 + + self.editorBox = gui.vBox(self, box="Editor", spacing=4) + self.splitCanvas.addWidget(self.editorBox) + + darkMode = QApplication.instance().property('darkMode') + scheme_name = 'Dark' if darkMode else 'Light' + syntax_highlighting_scheme = SYNTAX_HIGHLIGHTING_STYLES[scheme_name] + self.pygments_style_class = make_pygments_style(scheme_name) + + eFont = QFont(defaultFont) + eFont.setPointSize(defaultFontSize) + + # Fake Signature + + self.func_sig = func_sig = FunctionSignature( + self.editorBox, + syntax_highlighting_scheme, + eFont + ) + + # Editor + + editor = PythonEditor(self) + editor.setFont(eFont) + editor.setup_completer_appearance((300, 180), eFont) + + # Fake return + + return_stmt = ReturnStatement( + self.editorBox, + syntax_highlighting_scheme, + eFont + ) + self.return_stmt = return_stmt + + # Match indentation + + textEditBox = QWidget(self.editorBox) + textEditBox.setLayout(QHBoxLayout()) + char_4_width = QFontMetrics(eFont).horizontalAdvance('0000') + + @editor.viewport_margins_updated.connect + def _(width): + func_sig.setIndent(width) + textEditMargin = max(0, round(char_4_width - width)) + return_stmt.setIndent(textEditMargin + width) + textEditBox.layout().setContentsMargins( + textEditMargin, 0, 0, 0 + ) + + self.text = editor + textEditBox.layout().addWidget(editor) + self.editorBox.layout().addWidget(func_sig) + self.editorBox.layout().addWidget(textEditBox) + self.editorBox.layout().addWidget(return_stmt) + + self.editorBox.setAlignment(Qt.AlignVCenter) + self.text.setTabStopWidth(4) + + self.text.modificationChanged[bool].connect(self.onModificationChanged) + + # Controls + + self.editor_controls = gui.vBox(self.controlArea, box='Preferences') + + self.vim_box = gui.hBox(self.editor_controls, spacing=20) + self.vim_indicator = VimIndicator(self.vim_box) + + vim_sp = QSizePolicy( + QSizePolicy.Expanding, QSizePolicy.Fixed + ) + vim_sp.setRetainSizeWhenHidden(True) + self.vim_indicator.setSizePolicy(vim_sp) + + def enable_vim_mode(): + editor.vimModeEnabled = self.vimModeEnabled + self.vim_indicator.setVisible(self.vimModeEnabled) + enable_vim_mode() + + gui.checkBox( + self.vim_box, self, 'vimModeEnabled', 'Vim mode', + tooltip="Only for the coolest.", + callback=enable_vim_mode + ) + self.vim_box.layout().addWidget(self.vim_indicator) + @editor.vimModeIndicationChanged.connect + def _(color, text): + self.vim_indicator.indicator_color = color + self.vim_indicator.indicator_text = text + self.vim_indicator.update() + + # Library + + self.libraryListSource = [] + self._cachedDocuments = {} self.libraryList = itemmodels.PyListModel( [], self, @@ -492,8 +681,7 @@ def __init__(self): self.controlBox.layout().setSpacing(1) self.libraryView = QListView( - editTriggers=QListView.DoubleClicked | - QListView.EditKeyPressed, + editTriggers=QListView.DoubleClicked | QListView.EditKeyPressed, sizePolicy=QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred) ) @@ -548,23 +736,9 @@ def __init__(self): self.execute_button = gui.button(self.buttonsArea, self, 'Run', callback=self.commit) - run = QAction("Run script", self, triggered=self.commit, - shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_R)) - self.addAction(run) - - self.splitCanvas = QSplitter(Qt.Vertical, self.mainArea) - self.mainArea.layout().addWidget(self.splitCanvas) - - self.defaultFont = defaultFont = \ - "Monaco" if sys.platform == "darwin" else "Courier" - - self.textBox = gui.vBox(self.splitCanvas, 'Python Script') - self.text = PythonScriptEditor(self) - self.textBox.layout().addWidget(self.text) - - self.textBox.setAlignment(Qt.AlignVCenter) - - self.text.modificationChanged[bool].connect(self.onModificationChanged) + self.run_action = QAction("Run script", self, triggered=self.commit, + shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_R)) + self.addAction(self.run_action) self.saveAction = action = QAction("&Save", self.text) action.setToolTip("Save script to file") @@ -578,7 +752,6 @@ def __init__(self): self.console.document().setDefaultFont(QFont(defaultFont)) self.consoleBox.setAlignment(Qt.AlignBottom) self.splitCanvas.setSizes([2, 1]) - self.setAcceptDrops(True) self.controlArea.layout().addStretch(10) self._restoreState() @@ -631,6 +804,11 @@ def set_object(self, data, sig_id): self.handle_input(data, sig_id, "object") def handleNewSignals(self): + # update fake signature labels + self.func_sig.update_signal_text({ + n: len(getattr(self, n)) for n in self.signal_names + }) + self.commit() def selectedScriptIndex(self): @@ -655,8 +833,7 @@ def onAddScriptFromFile(self, *_): ) if filename: name = os.path.basename(filename) - # TODO: use `tokenize.detect_encoding` - with open(filename, encoding="utf-8") as f: + with tokenize.open(filename) as f: contents = f.read() self.libraryList.append(Script(name, contents, 0, filename)) self.setSelectedScript(len(self.libraryList) - 1) @@ -691,7 +868,9 @@ def documentForScript(self, script=0): doc.setDocumentLayout(QPlainTextDocumentLayout(doc)) doc.setPlainText(script.script) doc.setDefaultFont(QFont(self.defaultFont)) - doc.highlighter = PythonSyntaxHighlighter(doc) + doc.highlighter = PygmentsHighlighter(doc) + doc.highlighter.set_style(self.pygments_style_class) + doc.setDefaultFont(QFont(self.defaultFont, pointSize=self.defaultFontSize)) doc.modificationChanged[bool].connect(self.onModificationChanged) doc.setModified(False) self._cachedDocuments[script] = doc @@ -743,7 +922,7 @@ def saveScript(self): f.close() def initial_locals_state(self): - d = self.shared_namespaces.setdefault(self.signalManager, {}).copy() + d = {} for name in self.signal_names: value = getattr(self, name) all_values = list(value.values()) @@ -752,14 +931,6 @@ def initial_locals_state(self): d["in_" + name] = one_value return d - def update_namespace(self, namespace): - not_saved = reduce(set.union, - ({f"in_{name}s", f"in_{name}", f"out_{name}"} - for name in self.signal_names)) - self.shared_namespaces.setdefault(self.signalManager, {}).update( - {name: value for name, value in namespace.items() - if name not in not_saved}) - def commit(self): self.Error.clear() lcls = self.initial_locals_state() @@ -768,7 +939,6 @@ def commit(self): self.console.write("\nRunning script:\n") self.console.push("exec(_script)") self.console.new_prompt(sys.ps1) - self.update_namespace(self.console.locals) for signal in self.signal_names: out_var = self.console.locals.get("out_" + signal) signal_type = getattr(self.Outputs, signal).type @@ -780,6 +950,14 @@ def commit(self): out_var = None getattr(self.Outputs, signal).send(out_var) + def keyPressEvent(self, e): + if e.matches(QKeySequence.InsertLineSeparator): + # run on Shift+Enter, Ctrl+Enter + self.run_action.trigger() + e.accept() + else: + super().keyPressEvent(e) + def dragEnterEvent(self, event): # pylint: disable=no-self-use urls = event.mimeData().urls() if urls: @@ -788,12 +966,6 @@ def dragEnterEvent(self, event): # pylint: disable=no-self-use if c is not None: event.acceptProposedAction() - def dropEvent(self, event): - """Handle file drops""" - urls = event.mimeData().urls() - if urls: - self.text.pasteFile(urls[0]) - @classmethod def migrate_settings(cls, settings, version): if version is not None and version < 2: @@ -802,6 +974,10 @@ def migrate_settings(cls, settings, version): for s in scripts] # type: List[_ScriptData] settings["scriptLibrary"] = library + def onDeleteWidget(self): + self.text.terminate() + super().onDeleteWidget() + class OWPythonScriptDropHandler(SingleFileDropHandler): WIDGET = OWPythonScript diff --git a/Orange/widgets/data/owselectcolumns.py b/Orange/widgets/data/owselectcolumns.py index 86f99be760c..6b3cd527fe6 100644 --- a/Orange/widgets/data/owselectcolumns.py +++ b/Orange/widgets/data/owselectcolumns.py @@ -134,7 +134,8 @@ def match(self, context, domain, attrs, metas): def filter_value(self, setting, data, domain, attrs, metas): if setting.name != "domain_role_hints": - return super().filter_value(setting, data, domain, attrs, metas) + super().filter_value(setting, data, domain, attrs, metas) + return all_vars = attrs.copy() all_vars.update(metas) diff --git a/Orange/widgets/data/tests/test_owpythonscript.py b/Orange/widgets/data/tests/test_owpythonscript.py index b2f6f0a06c1..6158e372b5d 100644 --- a/Orange/widgets/data/tests/test_owpythonscript.py +++ b/Orange/widgets/data/tests/test_owpythonscript.py @@ -1,19 +1,30 @@ # Test methods with long descriptive names can omit docstrings -# pylint: disable=missing-docstring +# pylint: disable=missing-docstring, unused-wildcard-import +# pylint: disable=wildcard-import, protected-access import sys import unittest from AnyQt.QtCore import QMimeData, QUrl, QPoint, Qt -from AnyQt.QtGui import QDragEnterEvent, QDropEvent +from AnyQt.QtGui import QDragEnterEvent from Orange.data import Table from Orange.classification import LogisticRegressionLearner from Orange.tests import named_file from Orange.widgets.data.owpythonscript import OWPythonScript, \ read_file_content, Script, OWPythonScriptDropHandler -from Orange.widgets.tests.base import WidgetTest, DummySignalManager +from Orange.widgets.tests.base import WidgetTest from Orange.widgets.widget import OWWidget +# import tests for python editor +from Orange.widgets.data.utils.pythoneditor.tests.test_api import * +from Orange.widgets.data.utils.pythoneditor.tests.test_bracket_highlighter import * +from Orange.widgets.data.utils.pythoneditor.tests.test_draw_whitespace import * +from Orange.widgets.data.utils.pythoneditor.tests.test_edit import * +from Orange.widgets.data.utils.pythoneditor.tests.test_indent import * +from Orange.widgets.data.utils.pythoneditor.tests.test_indenter.test_python import * +from Orange.widgets.data.utils.pythoneditor.tests.test_rectangular_selection import * +from Orange.widgets.data.utils.pythoneditor.tests.test_vim import * + class TestOWPythonScript(WidgetTest): def setUp(self): @@ -176,7 +187,11 @@ def test_script_insert_mime_file(self): url = QUrl.fromLocalFile(fn) mime.setUrls([url]) self.widget.text.insertFromMimeData(mime) - self.assertEqual("test", self.widget.text.toPlainText()) + text = self.widget.text.toPlainText().split("print('Hello world')")[0] + self.assertTrue( + "'" + fn + "'", + text + ) self.widget.text.undo() self.assertEqual(previous, self.widget.text.toPlainText()) @@ -203,52 +218,6 @@ def _drag_enter_event(self, url): QPoint(0, 0), Qt.MoveAction, data, Qt.NoButton, Qt.NoModifier) - def test_dropEvent_replaces_file(self): - with named_file("test", suffix=".42") as fn: - previous = self.widget.text.toPlainText() - event = self._drop_event(QUrl.fromLocalFile(fn)) - self.widget.dropEvent(event) - self.assertEqual("test", self.widget.text.toPlainText()) - self.widget.text.undo() - self.assertEqual(previous, self.widget.text.toPlainText()) - - def _drop_event(self, url): - # make sure data does not get garbage collected before it used - # pylint: disable=attribute-defined-outside-init - self.event_data = data = QMimeData() - data.setUrls([QUrl(url)]) - - return QDropEvent( - QPoint(0, 0), Qt.MoveAction, data, - Qt.NoButton, Qt.NoModifier, QDropEvent.Drop) - - def test_shared_namespaces(self): - widget1 = self.create_widget(OWPythonScript) - widget2 = self.create_widget(OWPythonScript) - self.signal_manager = DummySignalManager() - widget3 = self.create_widget(OWPythonScript) - - self.send_signal(widget1.Inputs.data, self.iris, 1, widget=widget1) - widget1.text.setPlainText("x = 42\n" - "out_data = in_data\n") - widget1.execute_button.click() - self.assertIs( - self.get_output(widget1.Outputs.data, widget=widget1), - self.iris) - - widget2.text.setPlainText("out_object = 2 * x\n" - "out_data = in_data") - widget2.execute_button.click() - self.assertEqual( - self.get_output(widget1.Outputs.object, widget=widget2), - 84) - self.assertIsNone(self.get_output(widget1.Outputs.data, widget=widget2)) - - sys.last_traceback = None - widget3.text.setPlainText("out_object = 2 * x") - widget3.execute_button.click() - self.assertIsNotNone(sys.last_traceback) - def test_migrate(self): w = self.create_widget(OWPythonScript, { "libraryListSource": [Script("A", "1")], @@ -263,6 +232,27 @@ def test_restore(self): }) self.assertEqual(w.libraryListSource[0].name, "A") + def test_no_shared_namespaces(self): + """ + Previously, Python Script widgets in the same schema shared a namespace. + I (irgolic) think this is just a way to encourage users in writing + messy workflows with race conditions, so I encourage them to share + between Python Script widgets with Object signals. + """ + widget1 = self.create_widget(OWPythonScript) + widget2 = self.create_widget(OWPythonScript) + + click1 = widget1.execute_button.click + click2 = widget2.execute_button.click + + widget1.text.text = "x = 42" + click1() + + widget2.text.text = "y = 2 * x" + click2() + self.assertIn("NameError: name 'x' is not defined", + widget2.console.toPlainText()) + class TestOWPythonScriptDropHandler(unittest.TestCase): def test_canDropFile(self): @@ -275,3 +265,7 @@ def test_parametersFromFile(self): r = handler.parametersFromFile(__file__) item = r["scriptLibrary"][0] self.assertEqual(item["filename"], __file__) + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/data/utils/pythoneditor/__init__.py b/Orange/widgets/data/utils/pythoneditor/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Orange/widgets/data/utils/pythoneditor/brackethighlighter.py b/Orange/widgets/data/utils/pythoneditor/brackethighlighter.py new file mode 100644 index 00000000000..859e44d16b9 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/brackethighlighter.py @@ -0,0 +1,160 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" +import time + +from AnyQt.QtCore import Qt +from AnyQt.QtGui import QTextCursor, QColor +from AnyQt.QtWidgets import QTextEdit, QApplication + +# Bracket highlighter. +# Calculates list of QTextEdit.ExtraSelection + + +class _TimeoutException(UserWarning): + """Operation timeout happened + """ + + +class BracketHighlighter: + """Bracket highliter. + Calculates list of QTextEdit.ExtraSelection + + Currently, this class might be just a set of functions. + Probably, it will contain instance specific selection colors later + """ + MATCHED_COLOR = QColor('#0b0') + UNMATCHED_COLOR = QColor('#a22') + + _MAX_SEARCH_TIME_SEC = 0.02 + + _START_BRACKETS = '({[' + _END_BRACKETS = ')}]' + _ALL_BRACKETS = _START_BRACKETS + _END_BRACKETS + _OPOSITE_BRACKET = dict(zip(_START_BRACKETS + _END_BRACKETS, _END_BRACKETS + _START_BRACKETS)) + + # instance variable. None or ((block, columnIndex), (block, columnIndex)) + currentMatchedBrackets = None + + def _iterateDocumentCharsForward(self, block, startColumnIndex): + """Traverse document forward. Yield (block, columnIndex, char) + Raise _TimeoutException if time is over + """ + # Chars in the start line + endTime = time.time() + self._MAX_SEARCH_TIME_SEC + for columnIndex, char in list(enumerate(block.text()))[startColumnIndex:]: + yield block, columnIndex, char + block = block.next() + + # Next lines + while block.isValid(): + for columnIndex, char in enumerate(block.text()): + yield block, columnIndex, char + + if time.time() > endTime: + raise _TimeoutException('Time is over') + + block = block.next() + + def _iterateDocumentCharsBackward(self, block, startColumnIndex): + """Traverse document forward. Yield (block, columnIndex, char) + Raise _TimeoutException if time is over + """ + # Chars in the start line + endTime = time.time() + self._MAX_SEARCH_TIME_SEC + for columnIndex, char in reversed(list(enumerate(block.text()[:startColumnIndex]))): + yield block, columnIndex, char + block = block.previous() + + # Next lines + while block.isValid(): + for columnIndex, char in reversed(list(enumerate(block.text()))): + yield block, columnIndex, char + + if time.time() > endTime: + raise _TimeoutException('Time is over') + + block = block.previous() + + def _findMatchingBracket(self, bracket, qpart, block, columnIndex): + """Find matching bracket for the bracket. + Return (block, columnIndex) or (None, None) + Raise _TimeoutException, if time is over + """ + if bracket in self._START_BRACKETS: + charsGenerator = self._iterateDocumentCharsForward(block, columnIndex + 1) + else: + charsGenerator = self._iterateDocumentCharsBackward(block, columnIndex) + + depth = 1 + oposite = self._OPOSITE_BRACKET[bracket] + for b, c_index, char in charsGenerator: + if qpart.isCode(b, c_index): + if char == oposite: + depth -= 1 + if depth == 0: + return b, c_index + elif char == bracket: + depth += 1 + return None, None + + def _makeMatchSelection(self, block, columnIndex, matched): + """Make matched or unmatched QTextEdit.ExtraSelection + """ + selection = QTextEdit.ExtraSelection() + darkMode = QApplication.instance().property('darkMode') + + if matched: + fgColor = self.MATCHED_COLOR + else: + fgColor = self.UNMATCHED_COLOR + + selection.format.setForeground(fgColor) + # repaint hack + selection.format.setBackground(Qt.white if not darkMode else QColor('#111111')) + selection.cursor = QTextCursor(block) + selection.cursor.setPosition(block.position() + columnIndex) + selection.cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor) + + return selection + + def _highlightBracket(self, bracket, qpart, block, columnIndex): + """Highlight bracket and matching bracket + Return tuple of QTextEdit.ExtraSelection's + """ + try: + matchedBlock, matchedColumnIndex = self._findMatchingBracket(bracket, qpart, + block, columnIndex) + except _TimeoutException: # not found, time is over + return[] # highlight nothing + + if matchedBlock is not None: + self.currentMatchedBrackets = ((block, columnIndex), (matchedBlock, matchedColumnIndex)) + return [self._makeMatchSelection(block, columnIndex, True), + self._makeMatchSelection(matchedBlock, matchedColumnIndex, True)] + else: + self.currentMatchedBrackets = None + return [self._makeMatchSelection(block, columnIndex, False)] + + def extraSelections(self, qpart, block, columnIndex): + """List of QTextEdit.ExtraSelection's, which highlighte brackets + """ + blockText = block.text() + + if columnIndex < len(blockText) and \ + blockText[columnIndex] in self._ALL_BRACKETS and \ + qpart.isCode(block, columnIndex): + return self._highlightBracket(blockText[columnIndex], qpart, block, columnIndex) + elif columnIndex > 0 and \ + blockText[columnIndex - 1] in self._ALL_BRACKETS and \ + qpart.isCode(block, columnIndex - 1): + return self._highlightBracket(blockText[columnIndex - 1], qpart, block, columnIndex - 1) + else: + self.currentMatchedBrackets = None + return [] diff --git a/Orange/widgets/data/utils/pythoneditor/completer.py b/Orange/widgets/data/utils/pythoneditor/completer.py new file mode 100644 index 00000000000..d09e1757e2e --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/completer.py @@ -0,0 +1,578 @@ +import logging +import html +import sys +from collections import namedtuple +from os.path import join, dirname + +from AnyQt.QtCore import QObject, QSize +from AnyQt.QtCore import QPoint, Qt, Signal +from AnyQt.QtGui import (QFontMetrics, QIcon, QTextDocument, + QAbstractTextDocumentLayout) +from AnyQt.QtWidgets import (QApplication, QListWidget, QListWidgetItem, + QToolTip, QStyledItemDelegate, + QStyleOptionViewItem, QStyle) + +from qtconsole.base_frontend_mixin import BaseFrontendMixin + +log = logging.getLogger(__name__) + +DEFAULT_COMPLETION_ITEM_WIDTH = 250 + +JEDI_TYPES = frozenset({'module', 'class', 'instance', 'function', 'param', + 'path', 'keyword', 'property', 'statement', None}) + + +class HTMLDelegate(QStyledItemDelegate): + """With this delegate, a QListWidgetItem or a QTableItem can render HTML. + + Taken from https://stackoverflow.com/a/5443112/2399799 + """ + + def __init__(self, parent, margin=0): + super().__init__(parent) + self._margin = margin + + def _prepare_text_document(self, option, index): + # This logic must be shared between paint and sizeHint for consitency + options = QStyleOptionViewItem(option) + self.initStyleOption(options, index) + + doc = QTextDocument() + doc.setDocumentMargin(self._margin) + doc.setHtml(options.text) + icon_height = doc.size().height() - 2 + options.decorationSize = QSize(icon_height, icon_height) + return options, doc + + def paint(self, painter, option, index): + options, doc = self._prepare_text_document(option, index) + + style = (QApplication.style() if options.widget is None + else options.widget.style()) + options.text = "" + + # Note: We need to pass the options widget as an argument of + # drawControl to make sure the delegate is painted with a style + # consistent with the widget in which it is used. + # See spyder-ide/spyder#10677. + style.drawControl(QStyle.CE_ItemViewItem, options, painter, + options.widget) + + ctx = QAbstractTextDocumentLayout.PaintContext() + + textRect = style.subElementRect(QStyle.SE_ItemViewItemText, + options, None) + painter.save() + + painter.translate(textRect.topLeft() + QPoint(0, -3)) + doc.documentLayout().draw(painter, ctx) + painter.restore() + + def sizeHint(self, option, index): + _, doc = self._prepare_text_document(option, index) + return QSize(round(doc.idealWidth()), round(doc.size().height() - 2)) + + +class CompletionWidget(QListWidget): + """ + Modelled after spyder-ide's ComlpetionWidget. + Copyright © Spyder Project Contributors + Licensed under the terms of the MIT License + (see spyder/__init__.py in spyder-ide/spyder for details) + """ + ICON_MAP = {} + + sig_show_completions = Signal(object) + + # Signal with the info about the current completion item documentation + # str: completion name + # str: completion signature/documentation, + # QPoint: QPoint where the hint should be shown + sig_completion_hint = Signal(str, str, QPoint) + + def __init__(self, parent, ancestor): + super().__init__(ancestor) + self.textedit = parent + self._language = None + self.setWindowFlags(Qt.SubWindow | Qt.FramelessWindowHint) + self.hide() + self.itemActivated.connect(self.item_selected) + # self.currentRowChanged.connect(self.row_changed) + self.is_internal_console = False + self.completion_list = None + self.completion_position = None + self.automatic = False + self.display_index = [] + + # Setup item rendering + self.setItemDelegate(HTMLDelegate(self, margin=3)) + self.setMinimumWidth(DEFAULT_COMPLETION_ITEM_WIDTH) + + # Initial item height and width + fm = QFontMetrics(self.textedit.font()) + self.item_height = fm.height() + self.item_width = self.width() + + self.setStyleSheet('QListWidget::item:selected {' + 'background-color: lightgray;' + '}') + + def setup_appearance(self, size, font): + """Setup size and font of the completion widget.""" + self.resize(*size) + self.setFont(font) + fm = QFontMetrics(font) + self.item_height = fm.height() + + def is_empty(self): + """Check if widget is empty.""" + if self.count() == 0: + return True + return False + + def show_list(self, completion_list, position, automatic): + """Show list corresponding to position.""" + if not completion_list: + self.hide() + return + + self.automatic = automatic + + if position is None: + # Somehow the position was not saved. + # Hope that the current position is still valid + self.completion_position = self.textedit.textCursor().position() + + elif self.textedit.textCursor().position() < position: + # hide the text as we moved away from the position + self.hide() + return + + else: + self.completion_position = position + + self.completion_list = completion_list + + # Check everything is in order + self.update_current() + + # If update_current called close, stop loading + if not self.completion_list: + return + + # If only one, must be chosen if not automatic + single_match = self.count() == 1 + if single_match and not self.automatic: + self.item_selected(self.item(0)) + # signal used for testing + self.sig_show_completions.emit(completion_list) + return + + self.show() + self.setFocus() + self.raise_() + + self.textedit.position_widget_at_cursor(self) + + if not self.is_internal_console: + tooltip_point = self.rect().topRight() + tooltip_point = self.mapToGlobal(tooltip_point) + + if self.completion_list is not None: + for completion in self.completion_list: + completion['point'] = tooltip_point + + # Show hint for first completion element + self.setCurrentRow(0) + + # signal used for testing + self.sig_show_completions.emit(completion_list) + + def set_language(self, language): + """Set the completion language.""" + self._language = language.lower() + + def update_list(self, current_word): + """ + Update the displayed list by filtering self.completion_list based on + the current_word under the cursor (see check_can_complete). + + If we're not updating the list with new completions, we filter out + textEdit completions, since it's difficult to apply them correctly + after the user makes edits. + + If no items are left on the list the autocompletion should stop + """ + self.clear() + + self.display_index = [] + height = self.item_height + width = self.item_width + + if current_word: + for c in self.completion_list: + c['end'] = c['start'] + len(current_word) + + for i, completion in enumerate(self.completion_list): + text = completion['text'] + if not self.check_can_complete(text, current_word): + continue + item = QListWidgetItem() + self.set_item_display( + item, completion, height=height, width=width) + item.setData(Qt.UserRole, completion) + + self.addItem(item) + self.display_index.append(i) + + if self.count() == 0: + self.hide() + + def _get_cached_icon(self, name): + if name not in JEDI_TYPES: + log.error('%s is not a valid jedi type', name) + return None + if name not in self.ICON_MAP: + if name is None: + self.ICON_MAP[name] = QIcon() + else: + icon_path = join(dirname(__file__), '..', '..', 'icons', + 'pythonscript', name + '.svg') + self.ICON_MAP[name] = QIcon(icon_path) + return self.ICON_MAP[name] + + def set_item_display(self, item_widget, item_info, height, width): + """Set item text & icons using the info available.""" + item_label = item_info['text'] + item_type = item_info['type'] + + item_text = self.get_html_item_representation( + item_label, item_type, + height=height, width=width) + + item_widget.setText(item_text) + item_widget.setIcon(self._get_cached_icon(item_type)) + + @staticmethod + def get_html_item_representation(item_completion, item_type=None, + height=14, + width=250): + """Get HTML representation of and item.""" + height = str(height) + width = str(width) + + # Unfortunately, both old- and new-style Python string formatting + # have poor performance due to being implemented as functions that + # parse the format string. + # f-strings in new versions of Python are fast due to Python + # compiling them into efficient string operations, but to be + # compatible with old versions of Python, we manually join strings. + parts = [ + '', '', + + '', + ] + if item_type is not None: + parts.extend(['' + ]) + parts.extend([ + '', '
', + html.escape(item_completion).replace(' ', ' '), + '', + item_type, + '
', + ]) + + return ''.join(parts) + + def hide(self): + """Override Qt method.""" + self.completion_position = None + self.completion_list = None + self.clear() + self.textedit.setFocus() + tooltip = getattr(self.textedit, 'tooltip_widget', None) + if tooltip: + tooltip.hide() + + QListWidget.hide(self) + QToolTip.hideText() + + def keyPressEvent(self, event): + """Override Qt method to process keypress.""" + # pylint: disable=too-many-branches + text, key = event.text(), event.key() + alt = event.modifiers() & Qt.AltModifier + shift = event.modifiers() & Qt.ShiftModifier + ctrl = event.modifiers() & Qt.ControlModifier + altgr = event.modifiers() and (key == Qt.Key_AltGr) + # Needed to properly handle Neo2 and other keyboard layouts + # See spyder-ide/spyder#11293 + neo2_level4 = (key == 0) # AltGr (ISO_Level5_Shift) in Neo2 on Linux + modifier = shift or ctrl or alt or altgr or neo2_level4 + if key in (Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab): + # Check that what was selected can be selected, + # otherwise timing issues + item = self.currentItem() + if item is None: + item = self.item(0) + + if self.is_up_to_date(item=item): + self.item_selected(item=item) + else: + self.hide() + self.textedit.keyPressEvent(event) + elif key == Qt.Key_Escape: + self.hide() + elif key in (Qt.Key_Left, Qt.Key_Right) or text in ('.', ':'): + self.hide() + self.textedit.keyPressEvent(event) + elif key in (Qt.Key_Up, Qt.Key_Down, Qt.Key_PageUp, Qt.Key_PageDown, + Qt.Key_Home, Qt.Key_End) and not modifier: + if key == Qt.Key_Up and self.currentRow() == 0: + self.setCurrentRow(self.count() - 1) + elif key == Qt.Key_Down and self.currentRow() == self.count() - 1: + self.setCurrentRow(0) + else: + QListWidget.keyPressEvent(self, event) + elif len(text) > 0 or key == Qt.Key_Backspace: + self.textedit.keyPressEvent(event) + self.update_current() + elif modifier: + self.textedit.keyPressEvent(event) + else: + self.hide() + QListWidget.keyPressEvent(self, event) + + def is_up_to_date(self, item=None): + """ + Check if the selection is up to date. + """ + if self.is_empty(): + return False + if not self.is_position_correct(): + return False + if item is None: + item = self.currentItem() + current_word = self.textedit.get_current_word(completion=True) + completion = item.data(Qt.UserRole) + filter_text = completion['text'] + return self.check_can_complete(filter_text, current_word) + + @staticmethod + def check_can_complete(filter_text, current_word): + """Check if current_word matches filter_text.""" + if not filter_text: + return True + + if not current_word: + return True + + return str(filter_text).lower().startswith( + str(current_word).lower()) + + def is_position_correct(self): + """Check if the position is correct.""" + + if self.completion_position is None: + return False + + cursor_position = self.textedit.textCursor().position() + + # Can only go forward from the data we have + if cursor_position < self.completion_position: + return False + + completion_text = self.textedit.get_current_word_and_position( + completion=True) + + # If no text found, we must be at self.completion_position + if completion_text is None: + return self.completion_position == cursor_position + + completion_text, text_position = completion_text + completion_text = str(completion_text) + + # The position of text must compatible with completion_position + if not text_position <= self.completion_position <= ( + text_position + len(completion_text)): + return False + + return True + + def update_current(self): + """ + Update the displayed list. + """ + if not self.is_position_correct(): + self.hide() + return + + current_word = self.textedit.get_current_word(completion=True) + self.update_list(current_word) + # self.setFocus() + # self.raise_() + self.setCurrentRow(0) + + def focusOutEvent(self, event): + """Override Qt method.""" + event.ignore() + # Don't hide it on Mac when main window loses focus because + # keyboard input is lost. + # Fixes spyder-ide/spyder#1318. + if sys.platform == "darwin": + if event.reason() != Qt.ActiveWindowFocusReason: + self.hide() + else: + # Avoid an error when running tests that show + # the completion widget + try: + self.hide() + except RuntimeError: + pass + + def item_selected(self, item=None): + """Perform the item selected action.""" + if item is None: + item = self.currentItem() + + if item is not None and self.completion_position is not None: + self.textedit.insert_completion(item.data(Qt.UserRole), + self.completion_position) + self.hide() + + def trigger_completion_hint(self, row=None): + if not self.completion_list: + return + if row is None: + row = self.currentRow() + if row < 0 or len(self.completion_list) <= row: + return + + item = self.completion_list[row] + if 'point' not in item: + return + + if 'textEdit' in item: + insert_text = item['textEdit']['newText'] + else: + insert_text = item['insertText'] + + # Split by starting $ or language specific chars + chars = ['$'] + if self._language == 'python': + chars.append('(') + + for ch in chars: + insert_text = insert_text.split(ch)[0] + + self.sig_completion_hint.emit( + insert_text, + item['documentation'], + item['point']) + + # @Slot(int) + # def row_changed(self, row): + # """Set completion hint info and show it.""" + # self.trigger_completion_hint(row) + + +class Completer(BaseFrontendMixin, QObject): + """ + Uses qtconsole's kernel to generate jedi completions, showing a list. + """ + + def __init__(self, qpart): + QObject.__init__(self, qpart) + self._request_info = {} + self.ready = False + self._qpart = qpart + self._widget = CompletionWidget(self._qpart, self._qpart.parent()) + self._opened_automatically = True + + self._complete() + + def terminate(self): + """Object deleted. Cancel timer + """ + + def isVisible(self): + return self._widget.isVisible() + + def setup_appearance(self, size, font): + self._widget.setup_appearance(size, font) + + def invokeCompletion(self): + """Invoke completion manually""" + self._opened_automatically = False + self._complete() + + def invokeCompletionIfAvailable(self): + if not self._opened_automatically: + return + self._complete() + + def _show_completions(self, matches, pos): + self._widget.show_list(matches, pos, self._opened_automatically) + + def _close_completions(self): + self._widget.hide() + + def _complete(self): + """ Performs completion at the current cursor location. + """ + if not self.ready: + return + code = self._qpart.text + cursor_pos = self._qpart.textCursor().position() + self._send_completion_request(code, cursor_pos) + + def _send_completion_request(self, code, cursor_pos): + # Send the completion request to the kernel + msg_id = self.kernel_client.complete(code=code, cursor_pos=cursor_pos) + info = self._CompletionRequest(msg_id, code, cursor_pos) + self._request_info['complete'] = info + + # --------------------------------------------------------------------------- + # 'BaseFrontendMixin' abstract interface + # --------------------------------------------------------------------------- + + _CompletionRequest = namedtuple('_CompletionRequest', + ['id', 'code', 'pos']) + + def _handle_complete_reply(self, rep): + """Support Jupyter's improved completion machinery. + """ + info = self._request_info.get('complete') + if (info and info.id == rep['parent_header']['msg_id']): + content = rep['content'] + + if 'metadata' not in content or \ + '_jupyter_types_experimental' not in content['metadata']: + log.error('Jupyter API has changed, completions are unavailable.') + return + matches = content['metadata']['_jupyter_types_experimental'] + start = content['cursor_start'] + + start = max(start, 0) + + for m in matches: + if m['type'] == '': + m['type'] = None + + self._show_completions(matches, start) + self._opened_automatically = True + + def _handle_kernel_info_reply(self, _): + """ Called when the KernelManager channels have started listening or + when the frontend is assigned an already listening KernelManager. + """ + if not self.ready: + self.ready = True + + def _handle_kernel_restarted(self): + self.ready = True + + def _handle_kernel_died(self, _): + self.ready = False diff --git a/Orange/widgets/data/utils/pythoneditor/editor.py b/Orange/widgets/data/utils/pythoneditor/editor.py new file mode 100644 index 00000000000..883f9350df8 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/editor.py @@ -0,0 +1,1836 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" +import re +import sys + +from AnyQt.QtCore import Signal, Qt, QRect, QPoint +from AnyQt.QtGui import QColor, QPainter, QPalette, QTextCursor, QKeySequence, QTextBlock, \ + QTextFormat, QBrush, QPen, QTextCharFormat +from AnyQt.QtWidgets import QPlainTextEdit, QWidget, QTextEdit, QAction, QApplication + +from pygments.token import Token +from qtconsole.pygments_highlighter import PygmentsHighlighter, PygmentsBlockUserData + +from Orange.widgets.data.utils.pythoneditor.completer import Completer +from Orange.widgets.data.utils.pythoneditor.brackethighlighter import BracketHighlighter +from Orange.widgets.data.utils.pythoneditor.indenter import Indenter +from Orange.widgets.data.utils.pythoneditor.lines import Lines +from Orange.widgets.data.utils.pythoneditor.rectangularselection import RectangularSelection +from Orange.widgets.data.utils.pythoneditor.vim import Vim, isChar + + +# pylint: disable=protected-access +# pylint: disable=unused-argument +# pylint: disable=too-many-lines +# pylint: disable=too-many-branches +# pylint: disable=too-many-instance-attributes +# pylint: disable=too-many-public-methods + + +def setPositionInBlock(cursor, positionInBlock, anchor=QTextCursor.MoveAnchor): + return cursor.setPosition(cursor.block().position() + positionInBlock, anchor) + + +def iterateBlocksFrom(block): + """Generator, which iterates QTextBlocks from block until the End of a document + """ + while block.isValid(): + yield block + block = block.next() + + +def iterateBlocksBackFrom(block): + """Generator, which iterates QTextBlocks from block until the Start of a document + """ + while block.isValid(): + yield block + block = block.previous() + + +class PythonEditor(QPlainTextEdit): + userWarning = Signal(str) + languageChanged = Signal(str) + indentWidthChanged = Signal(int) + indentUseTabsChanged = Signal(bool) + eolChanged = Signal(str) + vimModeIndicationChanged = Signal(QColor, str) + vimModeEnabledChanged = Signal(bool) + + LINT_ERROR = 'e' + LINT_WARNING = 'w' + LINT_NOTE = 'n' + + _DEFAULT_EOL = '\n' + + _DEFAULT_COMPLETION_THRESHOLD = 3 + _DEFAULT_COMPLETION_ENABLED = True + + def __init__(self, *args): + QPlainTextEdit.__init__(self, *args) + + self.setAttribute(Qt.WA_KeyCompression, False) # vim can't process compressed keys + + self._lastKeyPressProcessedByParent = False + # toPlainText() takes a lot of time on long texts, therefore it is cached + self._cachedText = None + + self._fontBackup = self.font() + + self._eol = self._DEFAULT_EOL + self._indenter = Indenter(self) + self._lineLengthEdge = None + self._lineLengthEdgeColor = QColor(255, 0, 0, 128) + self._atomicModificationDepth = 0 + + self.drawIncorrectIndentation = True + self.drawAnyWhitespace = False + self._drawIndentations = True + self._drawSolidEdge = False + self._solidEdgeLine = EdgeLine(self) + self._solidEdgeLine.setVisible(False) + + self._rectangularSelection = RectangularSelection(self) + + """Sometimes color themes will be supported. + Now black on white is hardcoded in the highlighters. + Hardcode same palette for not highlighted text + """ + palette = self.palette() + # don't clear syntax highlighting when highlighting text + palette.setBrush(QPalette.HighlightedText, QBrush(Qt.NoBrush)) + if QApplication.instance().property('darkMode'): + palette.setColor(QPalette.Base, QColor('#111111')) + palette.setColor(QPalette.Text, QColor('#ffffff')) + palette.setColor(QPalette.Highlight, QColor('#444444')) + self._currentLineColor = QColor('#111111') + else: + palette.setColor(QPalette.Base, QColor('#ffffff')) + palette.setColor(QPalette.Text, QColor('#000000')) + self._currentLineColor = QColor('#ffffff') + self.setPalette(palette) + + self._bracketHighlighter = BracketHighlighter() + + self._lines = Lines(self) + + self.completionThreshold = self._DEFAULT_COMPLETION_THRESHOLD + self.completionEnabled = self._DEFAULT_COMPLETION_ENABLED + self._completer = Completer(self) + self.auto_invoke_completions = False + self.dot_invoke_completions = False + + doc = self.document() + highlighter = PygmentsHighlighter(doc) + doc.highlighter = highlighter + + self._vim = None + + self._initActions() + + self._line_number_margin = LineNumberArea(self) + self._marginWidth = -1 + + self._nonVimExtraSelections = [] + # we draw bracket highlighting, current line and extra selections by user + self._userExtraSelections = [] + self._userExtraSelectionFormat = QTextCharFormat() + self._userExtraSelectionFormat.setBackground(QBrush(QColor('#ffee00'))) + + self._lintMarks = {} + + self.cursorPositionChanged.connect(self._updateExtraSelections) + self.textChanged.connect(self._dropUserExtraSelections) + self.textChanged.connect(self._resetCachedText) + self.textChanged.connect(self._clearLintMarks) + + self._updateExtraSelections() + + def _initActions(self): + """Init shortcuts for text editing + """ + + def createAction(text, shortcut, slot, iconFileName=None): + """Create QAction with given parameters and add to the widget + """ + action = QAction(text, self) + # if iconFileName is not None: + # action.setIcon(getIcon(iconFileName)) + + keySeq = shortcut if isinstance(shortcut, QKeySequence) else QKeySequence(shortcut) + action.setShortcut(keySeq) + action.setShortcutContext(Qt.WidgetShortcut) + action.triggered.connect(slot) + + self.addAction(action) + + return action + + # custom Orange actions + self.commentLine = createAction('Toggle comment line', 'Ctrl+/', self._onToggleCommentLine) + + # scrolling + self.scrollUpAction = createAction('Scroll up', 'Ctrl+Up', + lambda: self._onShortcutScroll(down=False), + 'go-up') + self.scrollDownAction = createAction('Scroll down', 'Ctrl+Down', + lambda: self._onShortcutScroll(down=True), + 'go-down') + self.selectAndScrollUpAction = createAction('Select and scroll Up', 'Ctrl+Shift+Up', + lambda: self._onShortcutSelectAndScroll( + down=False)) + self.selectAndScrollDownAction = createAction('Select and scroll Down', 'Ctrl+Shift+Down', + lambda: self._onShortcutSelectAndScroll( + down=True)) + + # indentation + self.increaseIndentAction = createAction('Increase indentation', 'Tab', + self._onShortcutIndent, + 'format-indent-more') + self.decreaseIndentAction = \ + createAction('Decrease indentation', 'Shift+Tab', + lambda: self._indenter.onChangeSelectedBlocksIndent( + increase=False), + 'format-indent-less') + self.autoIndentLineAction = \ + createAction('Autoindent line', 'Ctrl+I', + self._indenter.onAutoIndentTriggered) + self.indentWithSpaceAction = \ + createAction('Indent with 1 space', 'Ctrl+Shift+Space', + lambda: self._indenter.onChangeSelectedBlocksIndent( + increase=True, + withSpace=True)) + self.unIndentWithSpaceAction = \ + createAction('Unindent with 1 space', 'Ctrl+Shift+Backspace', + lambda: self._indenter.onChangeSelectedBlocksIndent( + increase=False, + withSpace=True)) + + # editing + self.undoAction = createAction('Undo', QKeySequence.Undo, + self.undo, 'edit-undo') + self.redoAction = createAction('Redo', QKeySequence.Redo, + self.redo, 'edit-redo') + + self.moveLineUpAction = createAction('Move line up', 'Alt+Up', + lambda: self._onShortcutMoveLine(down=False), + 'go-up') + self.moveLineDownAction = createAction('Move line down', 'Alt+Down', + lambda: self._onShortcutMoveLine(down=True), + 'go-down') + self.deleteLineAction = createAction('Delete line', 'Alt+Del', + self._onShortcutDeleteLine, 'edit-delete') + self.cutLineAction = createAction('Cut line', 'Alt+X', + self._onShortcutCutLine, 'edit-cut') + self.copyLineAction = createAction('Copy line', 'Alt+C', + self._onShortcutCopyLine, 'edit-copy') + self.pasteLineAction = createAction('Paste line', 'Alt+V', + self._onShortcutPasteLine, 'edit-paste') + self.duplicateLineAction = createAction('Duplicate line', 'Alt+D', + self._onShortcutDuplicateLine) + + def _onToggleCommentLine(self): + cursor: QTextCursor = self.textCursor() + cursor.beginEditBlock() + + startBlock = self.document().findBlock(cursor.selectionStart()) + endBlock = self.document().findBlock(cursor.selectionEnd()) + + def lineIndentationLength(text): + return len(text) - len(text.lstrip()) + + def isHashCommentSelected(lines): + return all(not line.strip() or line.lstrip().startswith('#') for line in lines) + + blocks = [] + lines = [] + + block = startBlock + line = block.text() + if block != endBlock or line.strip(): + blocks += [block] + lines += [line] + while block != endBlock: + block = block.next() + line = block.text() + if line.strip(): + blocks += [block] + lines += [line] + + if isHashCommentSelected(lines): + # remove the hash comment + for block, text in zip(blocks, lines): + cursor = QTextCursor(block) + cursor.setPosition(block.position() + lineIndentationLength(text)) + for _ in range(lineIndentationLength(text[lineIndentationLength(text) + 1:]) + 1): + cursor.deleteChar() + else: + # add a hash comment + for block, text in zip(blocks, lines): + cursor = QTextCursor(block) + cursor.setPosition(block.position() + lineIndentationLength(text)) + cursor.insertText('# ') + + if endBlock == self.document().lastBlock(): + if endBlock.text().strip(): + cursor = QTextCursor(endBlock) + cursor.movePosition(QTextCursor.End) + self.setTextCursor(cursor) + self._insertNewBlock() + cursorBlock = endBlock.next() + else: + cursorBlock = endBlock + else: + cursorBlock = endBlock.next() + cursor = QTextCursor(cursorBlock) + cursor.movePosition(QTextCursor.EndOfBlock) + self.setTextCursor(cursor) + cursor.endEditBlock() + + def _onShortcutIndent(self): + cursor = self.textCursor() + if cursor.hasSelection(): + self._indenter.onChangeSelectedBlocksIndent(increase=True) + elif cursor.positionInBlock() == cursor.block().length() - 1 and \ + cursor.block().text().strip(): + self._onCompletion() + else: + self._indenter.onShortcutIndentAfterCursor() + + def _onShortcutScroll(self, down): + """Ctrl+Up/Down pressed, scroll viewport + """ + value = self.verticalScrollBar().value() + if down: + value += 1 + else: + value -= 1 + self.verticalScrollBar().setValue(value) + + def _onShortcutSelectAndScroll(self, down): + """Ctrl+Shift+Up/Down pressed. + Select line and scroll viewport + """ + cursor = self.textCursor() + cursor.movePosition(QTextCursor.Down if down else QTextCursor.Up, QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + self._onShortcutScroll(down) + + def _onShortcutHome(self, select): + """Home pressed. Run a state machine: + + 1. Not at the line beginning. Move to the beginning of the line or + the beginning of the indent, whichever is closest to the current + cursor position. + 2. At the line beginning. Move to the beginning of the indent. + 3. At the beginning of the indent. Go to the beginning of the block. + 4. At the beginning of the block. Go to the beginning of the indent. + """ + # Gather info for cursor state and movement. + cursor = self.textCursor() + text = cursor.block().text() + indent = len(text) - len(text.lstrip()) + anchor = QTextCursor.KeepAnchor if select else QTextCursor.MoveAnchor + + # Determine current state and move based on that. + if cursor.positionInBlock() == indent: + # We're at the beginning of the indent. Go to the beginning of the + # block. + cursor.movePosition(QTextCursor.StartOfBlock, anchor) + elif cursor.atBlockStart(): + # We're at the beginning of the block. Go to the beginning of the + # indent. + setPositionInBlock(cursor, indent, anchor) + else: + # Neither of the above. There's no way I can find to directly + # determine if we're at the beginning of a line. So, try moving and + # see if the cursor location changes. + pos = cursor.positionInBlock() + cursor.movePosition(QTextCursor.StartOfLine, anchor) + # If we didn't move, we were already at the beginning of the line. + # So, move to the indent. + if pos == cursor.positionInBlock(): + setPositionInBlock(cursor, indent, anchor) + # If we did move, check to see if the indent was closer to the + # cursor than the beginning of the indent. If so, move to the + # indent. + elif cursor.positionInBlock() < indent: + setPositionInBlock(cursor, indent, anchor) + + self.setTextCursor(cursor) + + def _selectLines(self, startBlockNumber, endBlockNumber): + """Select whole lines + """ + startBlock = self.document().findBlockByNumber(startBlockNumber) + endBlock = self.document().findBlockByNumber(endBlockNumber) + cursor = QTextCursor(startBlock) + cursor.setPosition(endBlock.position(), QTextCursor.KeepAnchor) + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + + def _selectedBlocks(self): + """Return selected blocks and tuple (startBlock, endBlock) + """ + cursor = self.textCursor() + return self.document().findBlock(cursor.selectionStart()), \ + self.document().findBlock(cursor.selectionEnd()) + + def _selectedBlockNumbers(self): + """Return selected block numbers and tuple (startBlockNumber, endBlockNumber) + """ + startBlock, endBlock = self._selectedBlocks() + return startBlock.blockNumber(), endBlock.blockNumber() + + def _onShortcutMoveLine(self, down): + """Move line up or down + Actually, not a selected text, but next or previous block is moved + TODO keep bookmarks when moving + """ + startBlock, endBlock = self._selectedBlocks() + + startBlockNumber = startBlock.blockNumber() + endBlockNumber = endBlock.blockNumber() + + def _moveBlock(block, newNumber): + text = block.text() + with self: + del self.lines[block.blockNumber()] + self.lines.insert(newNumber, text) + + if down: # move next block up + blockToMove = endBlock.next() + if not blockToMove.isValid(): + return + + _moveBlock(blockToMove, startBlockNumber) + + # self._selectLines(startBlockNumber + 1, endBlockNumber + 1) + else: # move previous block down + blockToMove = startBlock.previous() + if not blockToMove.isValid(): + return + + _moveBlock(blockToMove, endBlockNumber) + + # self._selectLines(startBlockNumber - 1, endBlockNumber - 1) + + def _selectedLinesSlice(self): + """Get slice of selected lines + """ + startBlockNumber, endBlockNumber = self._selectedBlockNumbers() + return slice(startBlockNumber, endBlockNumber + 1, 1) + + def _onShortcutDeleteLine(self): + """Delete line(s) under cursor + """ + del self.lines[self._selectedLinesSlice()] + + def _onShortcutCopyLine(self): + """Copy selected lines to the clipboard + """ + lines = self.lines[self._selectedLinesSlice()] + text = self._eol.join(lines) + QApplication.clipboard().setText(text) + + def _onShortcutPasteLine(self): + """Paste lines from the clipboard + """ + text = QApplication.clipboard().text() + if text: + with self: + if self.textCursor().hasSelection(): + startBlockNumber, _ = self._selectedBlockNumbers() + del self.lines[self._selectedLinesSlice()] + self.lines.insert(startBlockNumber, text) + else: + line, col = self.cursorPosition + if col > 0: + line = line + 1 + self.lines.insert(line, text) + + def _onShortcutCutLine(self): + """Cut selected lines to the clipboard + """ + self._onShortcutCopyLine() + self._onShortcutDeleteLine() + + def _onShortcutDuplicateLine(self): + """Duplicate selected text or current line + """ + cursor = self.textCursor() + if cursor.hasSelection(): # duplicate selection + text = cursor.selectedText() + selectionStart, selectionEnd = cursor.selectionStart(), cursor.selectionEnd() + cursor.setPosition(selectionEnd) + cursor.insertText(text) + # restore selection + cursor.setPosition(selectionStart) + cursor.setPosition(selectionEnd, QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + else: + line = cursor.blockNumber() + self.lines.insert(line + 1, self.lines[line]) + self.ensureCursorVisible() + + self._updateExtraSelections() # newly inserted text might be highlighted as braces + + def _onCompletion(self): + """Ctrl+Space handler. + Invoke completer if so configured + """ + if self._completer: + self._completer.invokeCompletion() + + @property + def kernel_client(self): + return self._completer.kernel_client + + @kernel_client.setter + def kernel_client(self, kernel_client): + self._completer.kernel_client = kernel_client + + @property + def kernel_manager(self): + return self._completer.kernel_manager + + @kernel_manager.setter + def kernel_manager(self, kernel_manager): + self._completer.kernel_manager = kernel_manager + + @property + def vimModeEnabled(self): + return self._vim is not None + + @vimModeEnabled.setter + def vimModeEnabled(self, enabled): + if enabled: + if self._vim is None: + self._vim = Vim(self) + self._vim.modeIndicationChanged.connect(self.vimModeIndicationChanged) + self.vimModeEnabledChanged.emit(True) + else: + if self._vim is not None: + self._vim.terminate() + self._vim = None + self.vimModeEnabledChanged.emit(False) + + @property + def vimModeIndication(self): + if self._vim is not None: + return self._vim.indication() + else: + return (None, None) + + @property + def selectedText(self): + text = self.textCursor().selectedText() + + # replace unicode paragraph separator with habitual \n + text = text.replace('\u2029', '\n') + + return text + + @selectedText.setter + def selectedText(self, text): + self.textCursor().insertText(text) + + @property + def cursorPosition(self): + cursor = self.textCursor() + return cursor.block().blockNumber(), cursor.positionInBlock() + + @cursorPosition.setter + def cursorPosition(self, pos): + line, col = pos + + line = min(line, len(self.lines) - 1) + lineText = self.lines[line] + + if col is not None: + col = min(col, len(lineText)) + else: + col = len(lineText) - len(lineText.lstrip()) + + cursor = QTextCursor(self.document().findBlockByNumber(line)) + setPositionInBlock(cursor, col) + self.setTextCursor(cursor) + + @property + def absCursorPosition(self): + return self.textCursor().position() + + @absCursorPosition.setter + def absCursorPosition(self, pos): + cursor = self.textCursor() + cursor.setPosition(pos) + self.setTextCursor(cursor) + + @property + def selectedPosition(self): + cursor = self.textCursor() + cursorLine, cursorCol = cursor.blockNumber(), cursor.positionInBlock() + + cursor.setPosition(cursor.anchor()) + startLine, startCol = cursor.blockNumber(), cursor.positionInBlock() + + return ((startLine, startCol), (cursorLine, cursorCol)) + + @selectedPosition.setter + def selectedPosition(self, pos): + anchorPos, cursorPos = pos + anchorLine, anchorCol = anchorPos + cursorLine, cursorCol = cursorPos + + anchorCursor = QTextCursor(self.document().findBlockByNumber(anchorLine)) + setPositionInBlock(anchorCursor, anchorCol) + + # just get absolute position + cursor = QTextCursor(self.document().findBlockByNumber(cursorLine)) + setPositionInBlock(cursor, cursorCol) + + anchorCursor.setPosition(cursor.position(), QTextCursor.KeepAnchor) + self.setTextCursor(anchorCursor) + + @property + def absSelectedPosition(self): + cursor = self.textCursor() + return cursor.anchor(), cursor.position() + + @absSelectedPosition.setter + def absSelectedPosition(self, pos): + anchorPos, cursorPos = pos + cursor = self.textCursor() + cursor.setPosition(anchorPos) + cursor.setPosition(cursorPos, QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + + def resetSelection(self): + """Reset selection. Nothing will be selected. + """ + cursor = self.textCursor() + cursor.setPosition(cursor.position()) + self.setTextCursor(cursor) + + @property + def eol(self): + return self._eol + + @eol.setter + def eol(self, eol): + if not eol in ('\r', '\n', '\r\n'): + raise ValueError("Invalid EOL value") + if eol != self._eol: + self._eol = eol + self.eolChanged.emit(self._eol) + + @property + def indentWidth(self): + return self._indenter.width + + @indentWidth.setter + def indentWidth(self, width): + if self._indenter.width != width: + self._indenter.width = width + self._updateTabStopWidth() + self.indentWidthChanged.emit(width) + + @property + def indentUseTabs(self): + return self._indenter.useTabs + + @indentUseTabs.setter + def indentUseTabs(self, use): + if use != self._indenter.useTabs: + self._indenter.useTabs = use + self.indentUseTabsChanged.emit(use) + + @property + def lintMarks(self): + return self._lintMarks + + @lintMarks.setter + def lintMarks(self, marks): + if self._lintMarks != marks: + self._lintMarks = marks + self.update() + + def _clearLintMarks(self): + if not self._lintMarks: + self._lintMarks = {} + self.update() + + @property + def drawSolidEdge(self): + return self._drawSolidEdge + + @drawSolidEdge.setter + def drawSolidEdge(self, val): + self._drawSolidEdge = val + if val: + self._setSolidEdgeGeometry() + self.viewport().update() + self._solidEdgeLine.setVisible(val and self._lineLengthEdge is not None) + + @property + def drawIndentations(self): + return self._drawIndentations + + @drawIndentations.setter + def drawIndentations(self, val): + self._drawIndentations = val + self.viewport().update() + + @property + def lineLengthEdge(self): + return self._lineLengthEdge + + @lineLengthEdge.setter + def lineLengthEdge(self, val): + if self._lineLengthEdge != val: + self._lineLengthEdge = val + self.viewport().update() + self._solidEdgeLine.setVisible(val is not None and self._drawSolidEdge) + + @property + def lineLengthEdgeColor(self): + return self._lineLengthEdgeColor + + @lineLengthEdgeColor.setter + def lineLengthEdgeColor(self, val): + if self._lineLengthEdgeColor != val: + self._lineLengthEdgeColor = val + if self._lineLengthEdge is not None: + self.viewport().update() + + @property + def currentLineColor(self): + return self._currentLineColor + + @currentLineColor.setter + def currentLineColor(self, val): + if self._currentLineColor != val: + self._currentLineColor = val + self.viewport().update() + + def replaceText(self, pos, length, text): + """Replace length symbols from ``pos`` with new text. + + If ``pos`` is an integer, it is interpreted as absolute position, + if a tuple - as ``(line, column)`` + """ + if isinstance(pos, tuple): + pos = self.mapToAbsPosition(*pos) + + endPos = pos + length + + if not self.document().findBlock(pos).isValid(): + raise IndexError('Invalid start position %d' % pos) + + if not self.document().findBlock(endPos).isValid(): + raise IndexError('Invalid end position %d' % endPos) + + cursor = QTextCursor(self.document()) + cursor.setPosition(pos) + cursor.setPosition(endPos, QTextCursor.KeepAnchor) + + cursor.insertText(text) + + def insertText(self, pos, text): + """Insert text at position + + If ``pos`` is an integer, it is interpreted as absolute position, + if a tuple - as ``(line, column)`` + """ + return self.replaceText(pos, 0, text) + + def updateViewport(self): + """Recalculates geometry for all the margins and the editor viewport + """ + cr = self.contentsRect() + currentX = cr.left() + top = cr.top() + height = cr.height() + + marginWidth = 0 + if not self._line_number_margin.isHidden(): + width = self._line_number_margin.width() + self._line_number_margin.setGeometry(QRect(currentX, top, width, height)) + currentX += width + marginWidth += width + + if self._marginWidth != marginWidth: + self._marginWidth = marginWidth + self.updateViewportMargins() + else: + self._setSolidEdgeGeometry() + + def updateViewportMargins(self): + """Sets the viewport margins and the solid edge geometry""" + self.setViewportMargins(self._marginWidth, 0, 0, 0) + self._setSolidEdgeGeometry() + + def setDocument(self, document) -> None: + super().setDocument(document) + self._lines.setDocument(document) + # forces margins to update after setting a new document + self.blockCountChanged.emit(self.blockCount()) + + def _updateExtraSelections(self): + """Highlight current line + """ + cursorColumnIndex = self.textCursor().positionInBlock() + + bracketSelections = self._bracketHighlighter.extraSelections(self, + self.textCursor().block(), + cursorColumnIndex) + + selections = self._currentLineExtraSelections() + \ + self._rectangularSelection.selections() + \ + bracketSelections + \ + self._userExtraSelections + + self._nonVimExtraSelections = selections + + if self._vim is None: + allSelections = selections + else: + allSelections = selections + self._vim.extraSelections() + + QPlainTextEdit.setExtraSelections(self, allSelections) + + def _updateVimExtraSelections(self): + QPlainTextEdit.setExtraSelections(self, + self._nonVimExtraSelections + self._vim.extraSelections()) + + def _setSolidEdgeGeometry(self): + """Sets the solid edge line geometry if needed""" + if self._lineLengthEdge is not None: + cr = self.contentsRect() + + # contents margin usually gives 1 + # cursor rectangle left edge for the very first character usually + # gives 4 + x = self.fontMetrics().width('9' * self._lineLengthEdge) + \ + self._marginWidth + \ + self.contentsMargins().left() + \ + self.__cursorRect(self.firstVisibleBlock(), 0, offset=0).left() + self._solidEdgeLine.setGeometry(QRect(x, cr.top(), 1, cr.bottom())) + + viewport_margins_updated = Signal(float) + + def setViewportMargins(self, left, top, right, bottom): + """ + Override to align function signature with first character. + """ + super().setViewportMargins(left, top, right, bottom) + + cursor = QTextCursor(self.firstVisibleBlock()) + setPositionInBlock(cursor, 0) + cursorRect = self.cursorRect(cursor).translated(0, 0) + + first_char_indent = self._marginWidth + \ + self.contentsMargins().left() + \ + cursorRect.left() + + self.viewport_margins_updated.emit(first_char_indent) + + def textBeforeCursor(self): + """Text in current block from start to cursor position + """ + cursor = self.textCursor() + return cursor.block().text()[:cursor.positionInBlock()] + + def keyPressEvent(self, event): + """QPlainTextEdit.keyPressEvent() implementation. + Catch events, which may not be catched with QShortcut and call slots + """ + self._lastKeyPressProcessedByParent = False + + cursor = self.textCursor() + + def shouldUnindentWithBackspace(): + text = cursor.block().text() + spaceAtStartLen = len(text) - len(text.lstrip()) + + return self.textBeforeCursor().endswith(self._indenter.text()) and \ + not cursor.hasSelection() and \ + cursor.positionInBlock() == spaceAtStartLen + + def atEnd(): + return cursor.positionInBlock() == cursor.block().length() - 1 + + def shouldAutoIndent(event): + return atEnd() and \ + event.text() and \ + event.text() in self._indenter.triggerCharacters() + + def backspaceOverwrite(): + with self: + cursor.deletePreviousChar() + cursor.insertText(' ') + setPositionInBlock(cursor, cursor.positionInBlock() - 1) + self.setTextCursor(cursor) + + def typeOverwrite(text): + """QPlainTextEdit records text input in replace mode as 2 actions: + delete char, and type char. Actions are undone separately. This is + workaround for the Qt bug""" + with self: + if not atEnd(): + cursor.deleteChar() + cursor.insertText(text) + + # mac specific shortcuts, + if sys.platform == 'darwin': + # it seems weird to delete line on CTRL+Backspace on Windows, + # that's for deleting words. But Mac's CMD maps to Qt's CTRL. + if event.key() == Qt.Key_Backspace and event.modifiers() == Qt.ControlModifier: + self.deleteLineAction.trigger() + event.accept() + return + if event.matches(QKeySequence.InsertLineSeparator): + event.ignore() + return + elif event.matches(QKeySequence.InsertParagraphSeparator): + if self._vim is not None: + if self._vim.keyPressEvent(event): + return + self._insertNewBlock() + elif event.matches(QKeySequence.Copy) and self._rectangularSelection.isActive(): + self._rectangularSelection.copy() + elif event.matches(QKeySequence.Cut) and self._rectangularSelection.isActive(): + self._rectangularSelection.cut() + elif self._rectangularSelection.isDeleteKeyEvent(event): + self._rectangularSelection.delete() + elif event.key() == Qt.Key_Insert and event.modifiers() == Qt.NoModifier: + if self._vim is not None: + self._vim.keyPressEvent(event) + else: + self.setOverwriteMode(not self.overwriteMode()) + elif event.key() == Qt.Key_Backspace and \ + shouldUnindentWithBackspace(): + self._indenter.onShortcutUnindentWithBackspace() + elif event.key() == Qt.Key_Backspace and \ + not cursor.hasSelection() and \ + self.overwriteMode() and \ + cursor.positionInBlock() > 0: + backspaceOverwrite() + elif self.overwriteMode() and \ + event.text() and \ + isChar(event) and \ + not cursor.hasSelection() and \ + cursor.positionInBlock() < cursor.block().length(): + typeOverwrite(event.text()) + if self._vim is not None: + self._vim.keyPressEvent(event) + elif event.matches(QKeySequence.MoveToStartOfLine): + if self._vim is not None and \ + self._vim.keyPressEvent(event): + return + else: + self._onShortcutHome(select=False) + elif event.matches(QKeySequence.SelectStartOfLine): + self._onShortcutHome(select=True) + elif self._rectangularSelection.isExpandKeyEvent(event): + self._rectangularSelection.onExpandKeyEvent(event) + elif shouldAutoIndent(event): + with self: + super().keyPressEvent(event) + self._indenter.autoIndentBlock(cursor.block(), event.text()) + else: + if self._vim is not None: + if self._vim.keyPressEvent(event): + return + + # make action shortcuts override keyboard events (non-default Qt behaviour) + for action in self.actions(): + seq = action.shortcut() + if seq.count() == 1 and seq[0] == event.key() | int(event.modifiers()): + action.trigger() + break + else: + self._lastKeyPressProcessedByParent = True + super().keyPressEvent(event) + + if event.key() == Qt.Key_Escape: + event.accept() + + def terminate(self): + """ Terminate Qutepart instance. + This method MUST be called before application stop to avoid crashes and + some other interesting effects + Call it on close to free memory and stop background highlighting + """ + self.text = '' + if self._completer: + self._completer.terminate() + + if self._vim is not None: + self._vim.terminate() + + def __enter__(self): + """Context management method. + Begin atomic modification + """ + self._atomicModificationDepth = self._atomicModificationDepth + 1 + if self._atomicModificationDepth == 1: + self.textCursor().beginEditBlock() + + def __exit__(self, exc_type, exc_value, traceback): + """Context management method. + End atomic modification + """ + self._atomicModificationDepth = self._atomicModificationDepth - 1 + if self._atomicModificationDepth == 0: + self.textCursor().endEditBlock() + return exc_type is None + + def setFont(self, font): + """Set font and update tab stop width + """ + self._fontBackup = font + QPlainTextEdit.setFont(self, font) + self._updateTabStopWidth() + + # text on line numbers may overlap, if font is bigger, than code font + # Note: the line numbers margin recalculates its width and if it has + # been changed then it calls updateViewport() which in turn will + # update the solid edge line geometry. So there is no need of an + # explicit call self._setSolidEdgeGeometry() here. + lineNumbersMargin = self._line_number_margin + if lineNumbersMargin: + lineNumbersMargin.setFont(font) + + def setup_completer_appearance(self, size, font): + self._completer.setup_appearance(size, font) + + def setAutoComplete(self, enabled): + self.auto_invoke_completions = enabled + + def showEvent(self, ev): + """ Qt 5.big automatically changes font when adding document to workspace. + Workaround this bug """ + super().setFont(self._fontBackup) + return super().showEvent(ev) + + def _updateTabStopWidth(self): + """Update tabstop width after font or indentation changed + """ + self.setTabStopWidth(self.fontMetrics().horizontalAdvance(' ' * self._indenter.width)) + + @property + def lines(self): + return self._lines + + @lines.setter + def lines(self, value): + if not isinstance(value, (list, tuple)) or \ + not all(isinstance(item, str) for item in value): + raise TypeError('Invalid new value of "lines" attribute') + self.setPlainText('\n'.join(value)) + + def _resetCachedText(self): + """Reset toPlainText() result cache + """ + self._cachedText = None + + @property + def text(self): + if self._cachedText is None: + self._cachedText = self.toPlainText() + + return self._cachedText + + @text.setter + def text(self, text): + self.setPlainText(text) + + def textForSaving(self): + """Get text with correct EOL symbols. Use this method for saving a file to storage + """ + lines = self.text.splitlines() + if self.text.endswith('\n'): # splitlines ignores last \n + lines.append('') + return self.eol.join(lines) + self.eol + + def _get_token_at(self, block, column): + dataObject = block.userData() + + if not hasattr(dataObject, 'tokens'): + tokens = list(self.document().highlighter._lexer.get_tokens_unprocessed(block.text())) + dataObject = PygmentsBlockUserData(**{ + 'syntax_stack': dataObject.syntax_stack, + 'tokens': tokens + }) + block.setUserData(dataObject) + else: + tokens = dataObject.tokens + + for next_token in tokens: + c, _, _ = next_token + if c > column: + break + token = next_token + _, token_type, _ = token + + return token_type + + def isComment(self, line, column): + """Check if character at column is a comment + """ + block = self.document().findBlockByNumber(line) + + # here, pygments' highlighter is implemented, so the dataobject + # that is originally defined in Qutepart isn't the same + + # so I'm using pygments' parser, storing it in the data object + + dataObject = block.userData() + if dataObject is None: + return False + if len(dataObject.syntax_stack) > 1: + return True + + token_type = self._get_token_at(block, column) + + def recursive_is_type(token, parent_token): + if token.parent is None: + return False + if token.parent is parent_token: + return True + return recursive_is_type(token.parent, parent_token) + + return recursive_is_type(token_type, Token.Comment) + + def isCode(self, blockOrBlockNumber, column): + """Check if text at given position is a code. + + If language is not known, or text is not parsed yet, ``True`` is returned + """ + if isinstance(blockOrBlockNumber, QTextBlock): + block = blockOrBlockNumber + else: + block = self.document().findBlockByNumber(blockOrBlockNumber) + + # here, pygments' highlighter is implemented, so the dataobject + # that is originally defined in Qutepart isn't the same + + # so I'm using pygments' parser, storing it in the data object + + dataObject = block.userData() + if dataObject is None: + return True + if len(dataObject.syntax_stack) > 1: + return False + + token_type = self._get_token_at(block, column) + + def recursive_is_type(token, parent_token): + if token.parent is None: + return False + if token.parent is parent_token: + return True + return recursive_is_type(token.parent, parent_token) + + return not any(recursive_is_type(token_type, non_code_token) + for non_code_token + in (Token.Comment, Token.String)) + + def _dropUserExtraSelections(self): + if self._userExtraSelections: + self.setExtraSelections([]) + + def setExtraSelections(self, selections): + """Set list of extra selections. + Selections are list of tuples ``(startAbsolutePosition, length)``. + Extra selections are reset on any text modification. + + This is reimplemented method of QPlainTextEdit, it has different signature. + Do not use QPlainTextEdit method + """ + + def _makeQtExtraSelection(startAbsolutePosition, length): + selection = QTextEdit.ExtraSelection() + cursor = QTextCursor(self.document()) + cursor.setPosition(startAbsolutePosition) + cursor.setPosition(startAbsolutePosition + length, QTextCursor.KeepAnchor) + selection.cursor = cursor + selection.format = self._userExtraSelectionFormat + return selection + + self._userExtraSelections = [_makeQtExtraSelection(*item) for item in selections] + self._updateExtraSelections() + + def mapToAbsPosition(self, line, column): + """Convert line and column number to absolute position + """ + block = self.document().findBlockByNumber(line) + if not block.isValid(): + raise IndexError("Invalid line index %d" % line) + if column >= block.length(): + raise IndexError("Invalid column index %d" % column) + return block.position() + column + + def mapToLineCol(self, absPosition): + """Convert absolute position to ``(line, column)`` + """ + block = self.document().findBlock(absPosition) + if not block.isValid(): + raise IndexError("Invalid absolute position %d" % absPosition) + + return (block.blockNumber(), + absPosition - block.position()) + + def resizeEvent(self, event): + """QWidget.resizeEvent() implementation. + Adjust line number area + """ + QPlainTextEdit.resizeEvent(self, event) + self.updateViewport() + + def _insertNewBlock(self): + """Enter pressed. + Insert properly indented block + """ + cursor = self.textCursor() + atStartOfLine = cursor.positionInBlock() == 0 + with self: + cursor.insertBlock() + if not atStartOfLine: # if whole line is moved down - just leave it as is + self._indenter.autoIndentBlock(cursor.block()) + self.ensureCursorVisible() + + def calculate_real_position(self, point): + x = point.x() + self._line_number_margin.width() + return QPoint(x, point.y()) + + def position_widget_at_cursor(self, widget): + # Retrieve current screen height + desktop = QApplication.desktop() + srect = desktop.availableGeometry(desktop.screenNumber(widget)) + + left, top, right, bottom = (srect.left(), srect.top(), + srect.right(), srect.bottom()) + ancestor = widget.parent() + if ancestor: + left = max(left, ancestor.x()) + top = max(top, ancestor.y()) + right = min(right, ancestor.x() + ancestor.width()) + bottom = min(bottom, ancestor.y() + ancestor.height()) + + point = self.cursorRect().bottomRight() + point = self.calculate_real_position(point) + point = self.mapToGlobal(point) + # Move to left of cursor if not enough space on right + widget_right = point.x() + widget.width() + if widget_right > right: + point.setX(point.x() - widget.width()) + # Push to right if not enough space on left + if point.x() < left: + point.setX(left) + + # Moving widget above if there is not enough space below + widget_bottom = point.y() + widget.height() + x_position = point.x() + if widget_bottom > bottom: + point = self.cursorRect().topRight() + point = self.mapToGlobal(point) + point.setX(x_position) + point.setY(point.y() - widget.height()) + + if ancestor is not None: + # Useful only if we set parent to 'ancestor' in __init__ + point = ancestor.mapFromGlobal(point) + + widget.move(point) + + def insert_completion(self, completion, completion_position): + """Insert a completion into the editor. + + completion_position is where the completion was generated. + + The replacement range is computed using the (LSP) completion's + textEdit field if it exists. Otherwise, we replace from the + start of the word under the cursor. + """ + if not completion: + return + + cursor = self.textCursor() + + start = completion['start'] + end = completion['end'] + text = completion['text'] + + cursor.setPosition(start) + cursor.setPosition(end, QTextCursor.KeepAnchor) + cursor.removeSelectedText() + cursor.insertText(text) + self.setTextCursor(cursor) + + def keyReleaseEvent(self, event): + if self._lastKeyPressProcessedByParent and self._completer is not None: + # A hacky way to do not show completion list after a event, processed by vim + + text = event.text() + textTyped = (text and + event.modifiers() in (Qt.NoModifier, Qt.ShiftModifier)) and \ + (text.isalpha() or text.isdigit() or text == '_') + dotTyped = text == '.' + + cursor = self.textCursor() + cursor.movePosition(QTextCursor.PreviousWord, QTextCursor.KeepAnchor) + importTyped = cursor.selectedText() in ['from ', 'import '] + + if (textTyped and self.auto_invoke_completions) \ + or dotTyped or importTyped: + self._completer.invokeCompletionIfAvailable() + + super().keyReleaseEvent(event) + + def mousePressEvent(self, mouseEvent): + if mouseEvent.modifiers() in RectangularSelection.MOUSE_MODIFIERS and \ + mouseEvent.button() == Qt.LeftButton: + self._rectangularSelection.mousePressEvent(mouseEvent) + else: + super().mousePressEvent(mouseEvent) + + def mouseMoveEvent(self, mouseEvent): + if mouseEvent.modifiers() in RectangularSelection.MOUSE_MODIFIERS and \ + mouseEvent.buttons() == Qt.LeftButton: + self._rectangularSelection.mouseMoveEvent(mouseEvent) + else: + super().mouseMoveEvent(mouseEvent) + + def _chooseVisibleWhitespace(self, text): + result = [False for _ in range(len(text))] + + lastNonSpaceColumn = len(text.rstrip()) - 1 + + # Draw not trailing whitespace + if self.drawAnyWhitespace: + # Any + for column, char in enumerate(text[:lastNonSpaceColumn]): + if char.isspace() and \ + (char == '\t' or + column == 0 or + text[column - 1].isspace() or + ((column + 1) < lastNonSpaceColumn and + text[column + 1].isspace())): + result[column] = True + elif self.drawIncorrectIndentation: + # Only incorrect + if self.indentUseTabs: + # Find big space groups + firstNonSpaceColumn = len(text) - len(text.lstrip()) + bigSpaceGroup = ' ' * self.indentWidth + column = 0 + while True: + column = text.find(bigSpaceGroup, column, lastNonSpaceColumn) + if column == -1 or column >= firstNonSpaceColumn: + break + + for index in range(column, column + self.indentWidth): + result[index] = True + while index < lastNonSpaceColumn and \ + text[index] == ' ': + result[index] = True + index += 1 + column = index + else: + # Find tabs: + column = 0 + while column != -1: + column = text.find('\t', column, lastNonSpaceColumn) + if column != -1: + result[column] = True + column += 1 + + # Draw trailing whitespace + if self.drawIncorrectIndentation or self.drawAnyWhitespace: + for column in range(lastNonSpaceColumn + 1, len(text)): + result[column] = True + + return result + + def _drawIndentMarkersAndEdge(self, paintEventRect): + """Draw indentation markers + """ + painter = QPainter(self.viewport()) + + def drawWhiteSpace(block, column, char): + leftCursorRect = self.__cursorRect(block, column, 0) + rightCursorRect = self.__cursorRect(block, column + 1, 0) + if leftCursorRect.top() == rightCursorRect.top(): # if on the same visual line + middleHeight = (leftCursorRect.top() + leftCursorRect.bottom()) / 2 + if char == ' ': + painter.setPen(Qt.transparent) + painter.setBrush(QBrush(Qt.gray)) + xPos = (leftCursorRect.x() + rightCursorRect.x()) / 2 + painter.drawRect(QRect(xPos, middleHeight, 2, 2)) + else: + painter.setPen(QColor(Qt.gray).lighter(factor=120)) + painter.drawLine(leftCursorRect.x() + 3, middleHeight, + rightCursorRect.x() - 3, middleHeight) + + def effectiveEdgePos(text): + """Position of edge in a block. + Defined by self._lineLengthEdge, but visible width of \t is more than 1, + therefore effective position depends on count and position of \t symbols + Return -1 if line is too short to have edge + """ + if self._lineLengthEdge is None: + return -1 + + tabExtraWidth = self.indentWidth - 1 + fullWidth = len(text) + (text.count('\t') * tabExtraWidth) + if fullWidth <= self._lineLengthEdge: + return -1 + + currentWidth = 0 + for pos, char in enumerate(text): + if char == '\t': + # Qt indents up to indentation level, so visible \t width depends on position + currentWidth += (self.indentWidth - (currentWidth % self.indentWidth)) + else: + currentWidth += 1 + if currentWidth > self._lineLengthEdge: + return pos + # line too narrow, probably visible \t width is small + return -1 + + def drawEdgeLine(block, edgePos): + painter.setPen(QPen(QBrush(self._lineLengthEdgeColor), 0)) + rect = self.__cursorRect(block, edgePos, 0) + painter.drawLine(rect.topLeft(), rect.bottomLeft()) + + def drawIndentMarker(block, column): + painter.setPen(QColor(Qt.darkGray).lighter()) + rect = self.__cursorRect(block, column, offset=0) + painter.drawLine(rect.topLeft(), rect.bottomLeft()) + + def drawIndentMarkers(block, text, column): + # this was 6 blocks deep ~irgolic + while text.startswith(self._indenter.text()) and \ + len(text) > indentWidthChars and \ + text[indentWidthChars].isspace(): + + if column != self._lineLengthEdge and \ + (block.blockNumber(), + column) != cursorPos: # looks ugly, if both drawn + # on some fonts line is drawn below the cursor, if offset is 1 + # Looks like Qt bug + drawIndentMarker(block, column) + + text = text[indentWidthChars:] + column += indentWidthChars + + indentWidthChars = len(self._indenter.text()) + cursorPos = self.cursorPosition + + for block in iterateBlocksFrom(self.firstVisibleBlock()): + blockGeometry = self.blockBoundingGeometry(block).translated(self.contentOffset()) + if blockGeometry.top() > paintEventRect.bottom(): + break + + if block.isVisible() and blockGeometry.toRect().intersects(paintEventRect): + + # Draw indent markers, if good indentation is not drawn + if self._drawIndentations: + text = block.text() + if not self.drawAnyWhitespace: + column = indentWidthChars + drawIndentMarkers(block, text, column) + + # Draw edge, but not over a cursor + if not self._drawSolidEdge: + edgePos = effectiveEdgePos(block.text()) + if edgePos not in (-1, cursorPos[1]): + drawEdgeLine(block, edgePos) + + if self.drawAnyWhitespace or \ + self.drawIncorrectIndentation: + text = block.text() + for column, draw in enumerate(self._chooseVisibleWhitespace(text)): + if draw: + drawWhiteSpace(block, column, text[column]) + + def paintEvent(self, event): + """Paint event + Draw indentation markers after main contents is drawn + """ + super().paintEvent(event) + self._drawIndentMarkersAndEdge(event.rect()) + + def _currentLineExtraSelections(self): + """QTextEdit.ExtraSelection, which highlightes current line + """ + if self._currentLineColor is None: + return [] + + def makeSelection(cursor): + selection = QTextEdit.ExtraSelection() + selection.format.setBackground(self._currentLineColor) + selection.format.setProperty(QTextFormat.FullWidthSelection, True) + cursor.clearSelection() + selection.cursor = cursor + return selection + + rectangularSelectionCursors = self._rectangularSelection.cursors() + if rectangularSelectionCursors: + return [makeSelection(cursor) \ + for cursor in rectangularSelectionCursors] + else: + return [makeSelection(self.textCursor())] + + def insertFromMimeData(self, source): + if source.hasFormat(self._rectangularSelection.MIME_TYPE): + self._rectangularSelection.paste(source) + elif source.hasUrls(): + cursor = self.textCursor() + filenames = [url.toLocalFile() for url in source.urls()] + text = ', '.join("'" + f.replace("'", "'\"'\"'") + "'" + for f in filenames) + cursor.insertText(text) + else: + super().insertFromMimeData(source) + + def __cursorRect(self, block, column, offset): + cursor = QTextCursor(block) + setPositionInBlock(cursor, column) + return self.cursorRect(cursor).translated(offset, 0) + + def get_current_word_and_position(self, completion=False, help_req=False, + valid_python_variable=True): + """ + Return current word, i.e. word at cursor position, and the start + position. + """ + cursor = self.textCursor() + cursor_pos = cursor.position() + + if cursor.hasSelection(): + # Removes the selection and moves the cursor to the left side + # of the selection: this is required to be able to properly + # select the whole word under cursor (otherwise, the same word is + # not selected when the cursor is at the right side of it): + cursor.setPosition(min([cursor.selectionStart(), + cursor.selectionEnd()])) + else: + # Checks if the first character to the right is a white space + # and if not, moves the cursor one word to the left (otherwise, + # if the character to the left do not match the "word regexp" + # (see below), the word to the left of the cursor won't be + # selected), but only if the first character to the left is not a + # white space too. + def is_space(move): + curs = self.textCursor() + curs.movePosition(move, QTextCursor.KeepAnchor) + return not str(curs.selectedText()).strip() + + def is_special_character(move): + """Check if a character is a non-letter including numbers.""" + curs = self.textCursor() + curs.movePosition(move, QTextCursor.KeepAnchor) + text_cursor = str(curs.selectedText()).strip() + return len( + re.findall(r'([^\d\W]\w*)', text_cursor, re.UNICODE)) == 0 + + if help_req: + if is_special_character(QTextCursor.PreviousCharacter): + cursor.movePosition(QTextCursor.NextCharacter) + elif is_special_character(QTextCursor.NextCharacter): + cursor.movePosition(QTextCursor.PreviousCharacter) + elif not completion: + if is_space(QTextCursor.NextCharacter): + if is_space(QTextCursor.PreviousCharacter): + return None + cursor.movePosition(QTextCursor.WordLeft) + else: + if is_space(QTextCursor.PreviousCharacter): + return None + if is_special_character(QTextCursor.NextCharacter): + cursor.movePosition(QTextCursor.WordLeft) + + cursor.select(QTextCursor.WordUnderCursor) + text = str(cursor.selectedText()) + startpos = cursor.selectionStart() + + # Find a valid Python variable name + if valid_python_variable: + match = re.findall(r'([^\d\W]\w*)', text, re.UNICODE) + if not match: + return None + else: + text = match[0] + + if completion: + text = text[:cursor_pos - startpos] + + return text, startpos + + def get_current_word(self, completion=False, help_req=False, + valid_python_variable=True): + """Return current word, i.e. word at cursor position.""" + ret = self.get_current_word_and_position( + completion=completion, + help_req=help_req, + valid_python_variable=valid_python_variable + ) + + if ret is not None: + return ret[0] + return None + + +class EdgeLine(QWidget): + def __init__(self, editor): + QWidget.__init__(self, editor) + self.__editor = editor + self.setAttribute(Qt.WA_TransparentForMouseEvents) + + def paintEvent(self, event): + painter = QPainter(self) + painter.fillRect(event.rect(), self.__editor.lineLengthEdgeColor) + + +class LineNumberArea(QWidget): + _LEFT_MARGIN = 5 + _RIGHT_MARGIN = 5 + + def __init__(self, parent): + """qpart: reference to the editor + name: margin identifier + bit_count: number of bits to be used by the margin + """ + super().__init__(parent) + + self._editor = parent + self._name = 'line_numbers' + self._bit_count = 0 + self._bitRange = None + self.__allocateBits() + + self._countCache = (-1, -1) + self._editor.updateRequest.connect(self.__updateRequest) + + self.__width = self.__calculateWidth() + self._editor.blockCountChanged.connect(self.__updateWidth) + + def __updateWidth(self, newBlockCount=None): + newWidth = self.__calculateWidth() + if newWidth != self.__width: + self.__width = newWidth + self._editor.updateViewport() + + def paintEvent(self, event): + """QWidget.paintEvent() implementation + """ + painter = QPainter(self) + painter.fillRect(event.rect(), self.palette().color(QPalette.Window)) + painter.setPen(Qt.black) + + block = self._editor.firstVisibleBlock() + blockNumber = block.blockNumber() + top = int( + self._editor.blockBoundingGeometry(block).translated( + self._editor.contentOffset()).top()) + bottom = top + int(self._editor.blockBoundingRect(block).height()) + + boundingRect = self._editor.blockBoundingRect(block) + availableWidth = self.__width - self._RIGHT_MARGIN - self._LEFT_MARGIN + availableHeight = self._editor.fontMetrics().height() + while block.isValid() and top <= event.rect().bottom(): + if block.isVisible() and bottom >= event.rect().top(): + number = str(blockNumber + 1) + painter.drawText(self._LEFT_MARGIN, top, + availableWidth, availableHeight, + Qt.AlignRight, number) + # if boundingRect.height() >= singleBlockHeight * 2: # wrapped block + # painter.fillRect(1, top + singleBlockHeight, + # self.__width - 2, + # boundingRect.height() - singleBlockHeight - 2, + # Qt.darkGreen) + + block = block.next() + boundingRect = self._editor.blockBoundingRect(block) + top = bottom + bottom = top + int(boundingRect.height()) + blockNumber += 1 + + def __calculateWidth(self): + digits = len(str(max(1, self._editor.blockCount()))) + return self._LEFT_MARGIN + self._editor.fontMetrics().horizontalAdvance( + '9') * digits + self._RIGHT_MARGIN + + def width(self): + """Desired width. Includes text and margins + """ + return self.__width + + def setFont(self, font): + super().setFont(font) + self.__updateWidth() + + def __allocateBits(self): + """Allocates the bit range depending on the required bit count + """ + if self._bit_count < 0: + raise Exception("A margin cannot request negative number of bits") + if self._bit_count == 0: + return + + # Build a list of occupied ranges + margins = [self._editor._line_number_margin] + + occupiedRanges = [] + for margin in margins: + bitRange = margin.getBitRange() + if bitRange is not None: + # pick the right position + added = False + for index, r in enumerate(occupiedRanges): + r = occupiedRanges[index] + if bitRange[1] < r[0]: + occupiedRanges.insert(index, bitRange) + added = True + break + if not added: + occupiedRanges.append(bitRange) + + vacant = 0 + for r in occupiedRanges: + if r[0] - vacant >= self._bit_count: + self._bitRange = (vacant, vacant + self._bit_count - 1) + return + vacant = r[1] + 1 + # Not allocated, i.e. grab the tail bits + self._bitRange = (vacant, vacant + self._bit_count - 1) + + def __updateRequest(self, rect, dy): + """Repaint line number area if necessary + """ + if dy: + self.scroll(0, dy) + elif self._countCache[0] != self._editor.blockCount() or \ + self._countCache[1] != self._editor.textCursor().block().lineCount(): + + # if block height not added to rect, last line number sometimes is not drawn + blockHeight = self._editor.blockBoundingRect(self._editor.firstVisibleBlock()).height() + + self.update(0, rect.y(), self.width(), rect.height() + round(blockHeight)) + self._countCache = ( + self._editor.blockCount(), self._editor.textCursor().block().lineCount()) + + if rect.contains(self._editor.viewport().rect()): + self._editor.updateViewportMargins() + + def getName(self): + """Provides the margin identifier + """ + return self._name + + def getBitRange(self): + """None or inclusive bits used pair, + e.g. (2,4) => 3 bits used 2nd, 3rd and 4th + """ + return self._bitRange + + def setBlockValue(self, block, value): + """Sets the required value to the block without damaging the other bits + """ + if self._bit_count == 0: + raise Exception("The margin '" + self._name + + "' did not allocate any bits for the values") + if value < 0: + raise Exception("The margin '" + self._name + + "' must be a positive integer") + + if value >= 2 ** self._bit_count: + raise Exception("The margin '" + self._name + + "' value exceeds the allocated bit range") + + newMarginValue = value << self._bitRange[0] + currentUserState = block.userState() + + if currentUserState in [0, -1]: + block.setUserState(newMarginValue) + else: + marginMask = 2 ** self._bit_count - 1 + otherMarginsValue = currentUserState & ~marginMask + block.setUserState(newMarginValue | otherMarginsValue) + + def getBlockValue(self, block): + """Provides the previously set block value respecting the bits range. + 0 value and not marked block are treated the same way and 0 is + provided. + """ + if self._bit_count == 0: + raise Exception("The margin '" + self._name + + "' did not allocate any bits for the values") + val = block.userState() + if val in [0, -1]: + return 0 + + # Shift the value to the right + val >>= self._bitRange[0] + + # Apply the mask to the value + mask = 2 ** self._bit_count - 1 + val &= mask + return val + + def hide(self): + """Override the QWidget::hide() method to properly recalculate the + editor viewport. + """ + if not self.isHidden(): + super().hide() + self._editor.updateViewport() + + def show(self): + """Override the QWidget::show() method to properly recalculate the + editor viewport. + """ + if self.isHidden(): + super().show() + self._editor.updateViewport() + + def setVisible(self, val): + """Override the QWidget::setVisible(bool) method to properly + recalculate the editor viewport. + """ + if val != self.isVisible(): + if val: + super().setVisible(True) + else: + super().setVisible(False) + self._editor.updateViewport() + + # Convenience methods + + def clear(self): + """Convenience method to reset all the block values to 0 + """ + if self._bit_count == 0: + return + + block = self._editor.document().begin() + while block.isValid(): + if self.getBlockValue(block): + self.setBlockValue(block, 0) + block = block.next() + + # Methods for 1-bit margins + def isBlockMarked(self, block): + return self.getBlockValue(block) != 0 + + def toggleBlockMark(self, block): + self.setBlockValue(block, 0 if self.isBlockMarked(block) else 1) diff --git a/Orange/widgets/data/utils/pythoneditor/indenter.py b/Orange/widgets/data/utils/pythoneditor/indenter.py new file mode 100644 index 00000000000..6ec237e3ef1 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/indenter.py @@ -0,0 +1,530 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" +from PyQt5.QtGui import QTextCursor + +# pylint: disable=pointless-string-statement + +MAX_SEARCH_OFFSET_LINES = 128 + + +class Indenter: + """Qutepart functionality, related to indentation + + Public attributes: + width Indent width + useTabs Indent uses Tabs (instead of spaces) + """ + _DEFAULT_INDENT_WIDTH = 4 + _DEFAULT_INDENT_USE_TABS = False + + def __init__(self, qpart): + self._qpart = qpart + + self.width = self._DEFAULT_INDENT_WIDTH + self.useTabs = self._DEFAULT_INDENT_USE_TABS + + self._smartIndenter = IndentAlgPython(qpart, self) + + def text(self): + """Get indent text as \t or string of spaces + """ + if self.useTabs: + return '\t' + else: + return ' ' * self.width + + def triggerCharacters(self): + """Trigger characters for smart indentation""" + return self._smartIndenter.TRIGGER_CHARACTERS + + def autoIndentBlock(self, block, char='\n'): + """Indent block after Enter pressed or trigger character typed + """ + currentText = block.text() + spaceAtStartLen = len(currentText) - len(currentText.lstrip()) + currentIndent = currentText[:spaceAtStartLen] + indent = self._smartIndenter.computeIndent(block, char) + if indent is not None and indent != currentIndent: + self._qpart.replaceText(block.position(), spaceAtStartLen, indent) + + def onChangeSelectedBlocksIndent(self, increase, withSpace=False): + """Tab or Space pressed and few blocks are selected, or Shift+Tab pressed + Insert or remove text from the beginning of blocks + """ + + def blockIndentation(block): + text = block.text() + return text[:len(text) - len(text.lstrip())] + + def cursorAtSpaceEnd(block): + cursor = QTextCursor(block) + cursor.setPosition(block.position() + len(blockIndentation(block))) + return cursor + + def indentBlock(block): + cursor = cursorAtSpaceEnd(block) + cursor.insertText(' ' if withSpace else self.text()) + + def spacesCount(text): + return len(text) - len(text.rstrip(' ')) + + def unIndentBlock(block): + currentIndent = blockIndentation(block) + + if currentIndent.endswith('\t'): + charsToRemove = 1 + elif withSpace: + charsToRemove = 1 if currentIndent else 0 + else: + if self.useTabs: + charsToRemove = min(spacesCount(currentIndent), self.width) + else: # spaces + if currentIndent.endswith(self.text()): # remove indent level + charsToRemove = self.width + else: # remove all spaces + charsToRemove = min(spacesCount(currentIndent), self.width) + + if charsToRemove: + cursor = cursorAtSpaceEnd(block) + cursor.setPosition(cursor.position() - charsToRemove, QTextCursor.KeepAnchor) + cursor.removeSelectedText() + + cursor = self._qpart.textCursor() + + startBlock = self._qpart.document().findBlock(cursor.selectionStart()) + endBlock = self._qpart.document().findBlock(cursor.selectionEnd()) + if (cursor.selectionStart() != cursor.selectionEnd() and + endBlock.position() == cursor.selectionEnd() and + endBlock.previous().isValid()): + # do not indent not selected line if indenting multiple lines + endBlock = endBlock.previous() + + indentFunc = indentBlock if increase else unIndentBlock + + if startBlock != endBlock: # indent multiply lines + stopBlock = endBlock.next() + + block = startBlock + + with self._qpart: + while block != stopBlock: + indentFunc(block) + block = block.next() + + newCursor = QTextCursor(startBlock) + newCursor.setPosition(endBlock.position() + len(endBlock.text()), + QTextCursor.KeepAnchor) + self._qpart.setTextCursor(newCursor) + else: # indent 1 line + indentFunc(startBlock) + + def onShortcutIndentAfterCursor(self): + """Tab pressed and no selection. Insert text after cursor + """ + cursor = self._qpart.textCursor() + + def insertIndent(): + if self.useTabs: + cursor.insertText('\t') + else: # indent to integer count of indents from line start + charsToInsert = self.width - (len(self._qpart.textBeforeCursor()) % self.width) + cursor.insertText(' ' * charsToInsert) + + if cursor.positionInBlock() == 0: # if no any indent - indent smartly + block = cursor.block() + self.autoIndentBlock(block, '') + + # if no smart indentation - just insert one indent + if self._qpart.textBeforeCursor() == '': + insertIndent() + else: + insertIndent() + + def onShortcutUnindentWithBackspace(self): + """Backspace pressed, unindent + """ + assert self._qpart.textBeforeCursor().endswith(self.text()) + + charsToRemove = len(self._qpart.textBeforeCursor()) % len(self.text()) + if charsToRemove == 0: + charsToRemove = len(self.text()) + + cursor = self._qpart.textCursor() + cursor.setPosition(cursor.position() - charsToRemove, QTextCursor.KeepAnchor) + cursor.removeSelectedText() + + def onAutoIndentTriggered(self): + """Indent current line or selected lines + """ + cursor = self._qpart.textCursor() + + startBlock = self._qpart.document().findBlock(cursor.selectionStart()) + endBlock = self._qpart.document().findBlock(cursor.selectionEnd()) + + if startBlock != endBlock: # indent multiply lines + stopBlock = endBlock.next() + + block = startBlock + + with self._qpart: + while block != stopBlock: + self.autoIndentBlock(block, '') + block = block.next() + else: # indent 1 line + self.autoIndentBlock(startBlock, '') + + +class IndentAlgBase: + """Base class for indenters + """ + TRIGGER_CHARACTERS = "" # indenter is called, when user types Enter of one of trigger chars + + def __init__(self, qpart, indenter): + self._qpart = qpart + self._indenter = indenter + + def indentBlock(self, block): + """Indent the block + """ + self._setBlockIndent(block, self.computeIndent(block, '')) + + def computeIndent(self, block, char): + """Compute indent for the block. + Basic alorightm, which knows nothing about programming languages + May be used by child classes + """ + prevBlockText = block.previous().text() # invalid block returns empty text + if char == '\n' and \ + prevBlockText.strip() == '': # continue indentation, if no text + return self._prevBlockIndent(block) + else: # be smart + return self.computeSmartIndent(block, char) + + def computeSmartIndent(self, block, char): + """Compute smart indent. + Block is current block. + Char is typed character. \n or one of trigger chars + Return indentation text, or None, if indentation shall not be modified + + Implementation might return self._prevNonEmptyBlockIndent(), if doesn't have + any ideas, how to indent text better + """ + raise NotImplementedError() + + def _qpartIndent(self): + """Return text previous block, which is non empty (contains something, except spaces) + Return '', if not found + """ + return self._indenter.text() + + def _increaseIndent(self, indent): + """Add 1 indentation level + """ + return indent + self._qpartIndent() + + def _decreaseIndent(self, indent): + """Remove 1 indentation level + """ + if indent.endswith(self._qpartIndent()): + return indent[:-len(self._qpartIndent())] + else: # oops, strange indentation, just return previous indent + return indent + + def _makeIndentFromWidth(self, width): + """Make indent text with specified with. + Contains width count of spaces, or tabs and spaces + """ + if self._indenter.useTabs: + tabCount, spaceCount = divmod(width, self._indenter.width) + return ('\t' * tabCount) + (' ' * spaceCount) + else: + return ' ' * width + + def _makeIndentAsColumn(self, block, column, offset=0): + """ Make indent equal to column indent. + Shiftted by offset + """ + blockText = block.text() + textBeforeColumn = blockText[:column] + tabCount = textBeforeColumn.count('\t') + + visibleColumn = column + (tabCount * (self._indenter.width - 1)) + return self._makeIndentFromWidth(visibleColumn + offset) + + def _setBlockIndent(self, block, indent): + """Set blocks indent. Modify text in qpart + """ + currentIndent = self._blockIndent(block) + self._qpart.replaceText((block.blockNumber(), 0), len(currentIndent), indent) + + @staticmethod + def iterateBlocksFrom(block): + """Generator, which iterates QTextBlocks from block until the End of a document + But, yields not more than MAX_SEARCH_OFFSET_LINES + """ + count = 0 + while block.isValid() and count < MAX_SEARCH_OFFSET_LINES: + yield block + block = block.next() + count += 1 + + @staticmethod + def iterateBlocksBackFrom(block): + """Generator, which iterates QTextBlocks from block until the Start of a document + But, yields not more than MAX_SEARCH_OFFSET_LINES + """ + count = 0 + while block.isValid() and count < MAX_SEARCH_OFFSET_LINES: + yield block + block = block.previous() + count += 1 + + @classmethod + def iterateCharsBackwardFrom(cls, block, column): + if column is not None: + text = block.text()[:column] + for index, char in enumerate(reversed(text)): + yield block, len(text) - index - 1, char + block = block.previous() + + for b in cls.iterateBlocksBackFrom(block): + for index, char in enumerate(reversed(b.text())): + yield b, len(b.text()) - index - 1, char + + def findBracketBackward(self, block, column, bracket): + """Search for a needle and return (block, column) + Raise ValueError, if not found + """ + if bracket in ('(', ')'): + opening = '(' + closing = ')' + elif bracket in ('[', ']'): + opening = '[' + closing = ']' + elif bracket in ('{', '}'): + opening = '{' + closing = '}' + else: + raise AssertionError('Invalid bracket "%s"' % bracket) + + depth = 1 + for foundBlock, foundColumn, char in self.iterateCharsBackwardFrom(block, column): + if not self._qpart.isComment(foundBlock.blockNumber(), foundColumn): + if char == opening: + depth = depth - 1 + elif char == closing: + depth = depth + 1 + + if depth == 0: + return foundBlock, foundColumn + raise ValueError('Not found') + + def findAnyBracketBackward(self, block, column): + """Search for a needle and return (block, column) + Raise ValueError, if not found + + NOTE this methods ignores strings and comments + """ + depth = {'()': 1, + '[]': 1, + '{}': 1 + } + + for foundBlock, foundColumn, char in self.iterateCharsBackwardFrom(block, column): + if self._qpart.isCode(foundBlock.blockNumber(), foundColumn): + for brackets in depth: + opening, closing = brackets + if char == opening: + depth[brackets] -= 1 + if depth[brackets] == 0: + return foundBlock, foundColumn + elif char == closing: + depth[brackets] += 1 + raise ValueError('Not found') + + @staticmethod + def _lastNonSpaceChar(block): + textStripped = block.text().rstrip() + if textStripped: + return textStripped[-1] + else: + return '' + + @staticmethod + def _firstNonSpaceChar(block): + textStripped = block.text().lstrip() + if textStripped: + return textStripped[0] + else: + return '' + + @staticmethod + def _firstNonSpaceColumn(text): + return len(text) - len(text.lstrip()) + + @staticmethod + def _lastNonSpaceColumn(text): + return len(text.rstrip()) + + @classmethod + def _lineIndent(cls, text): + return text[:cls._firstNonSpaceColumn(text)] + + @classmethod + def _blockIndent(cls, block): + if block.isValid(): + return cls._lineIndent(block.text()) + else: + return '' + + @classmethod + def _prevBlockIndent(cls, block): + prevBlock = block.previous() + + if not block.isValid(): + return '' + + return cls._lineIndent(prevBlock.text()) + + @classmethod + def _prevNonEmptyBlockIndent(cls, block): + return cls._blockIndent(cls._prevNonEmptyBlock(block)) + + @staticmethod + def _prevNonEmptyBlock(block): + if not block.isValid(): + return block + + block = block.previous() + while block.isValid() and \ + len(block.text().strip()) == 0: + block = block.previous() + return block + + @staticmethod + def _nextNonEmptyBlock(block): + if not block.isValid(): + return block + + block = block.next() + while block.isValid() and \ + len(block.text().strip()) == 0: + block = block.next() + return block + + @staticmethod + def _nextNonSpaceColumn(block, column): + """Returns the column with a non-whitespace characters + starting at the given cursor position and searching forwards. + """ + textAfter = block.text()[column:] + if textAfter.strip(): + spaceLen = len(textAfter) - len(textAfter.lstrip()) + return column + spaceLen + else: + return -1 + + +class IndentAlgPython(IndentAlgBase): + """Indenter for Python language. + """ + + def _computeSmartIndent(self, block, column): + """Compute smart indent for case when cursor is on (block, column) + """ + lineStripped = block.text()[:column].strip() # empty text from invalid block is ok + spaceLen = len(block.text()) - len(block.text().lstrip()) + + """Move initial search position to bracket start, if bracket was closed + l = [1, + 2]| + """ + if lineStripped and \ + lineStripped[-1] in ')]}': + try: + backward = self.findBracketBackward(block, spaceLen + len(lineStripped) - 1, + lineStripped[-1]) + foundBlock, foundColumn = backward + except ValueError: + pass + else: + return self._computeSmartIndent(foundBlock, foundColumn) + + """Unindent if hanging indentation finished + func(a, + another_func(a, + b),| + """ + if len(lineStripped) > 1 and \ + lineStripped[-1] == ',' and \ + lineStripped[-2] in ')]}': + + try: + foundBlock, foundColumn = self.findBracketBackward(block, + len(block.text()[ + :column].rstrip()) - 2, + lineStripped[-2]) + except ValueError: + pass + else: + return self._computeSmartIndent(foundBlock, foundColumn) + + """Check hanging indentation + call_func(x, + y, + z + But + call_func(x, + y, + z + """ + try: + foundBlock, foundColumn = self.findAnyBracketBackward(block, + column) + except ValueError: + pass + else: + # indent this way only line, which contains 'y', not 'z' + if foundBlock.blockNumber() == block.blockNumber(): + return self._makeIndentAsColumn(foundBlock, foundColumn + 1) + + # finally, a raise, pass, and continue should unindent + if lineStripped in ('continue', 'break', 'pass', 'raise', 'return') or \ + lineStripped.startswith('raise ') or \ + lineStripped.startswith('return '): + return self._decreaseIndent(self._blockIndent(block)) + + """ + for: + + func(a, + b): + """ + if lineStripped.endswith(':'): + newColumn = spaceLen + len(lineStripped) - 1 + prevIndent = self._computeSmartIndent(block, newColumn) + return self._increaseIndent(prevIndent) + + """ Generally, when a brace is on its own at the end of a regular line + (i.e a data structure is being started), indent is wanted. + For example: + dictionary = { + 'foo': 'bar', + } + """ + if lineStripped.endswith('{['): + return self._increaseIndent(self._blockIndent(block)) + + return self._blockIndent(block) + + def computeSmartIndent(self, block, char): + block = self._prevNonEmptyBlock(block) + column = len(block.text()) + return self._computeSmartIndent(block, column) diff --git a/Orange/widgets/data/utils/pythoneditor/lines.py b/Orange/widgets/data/utils/pythoneditor/lines.py new file mode 100644 index 00000000000..8e7d31cf887 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/lines.py @@ -0,0 +1,189 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" +from PyQt5.QtGui import QTextCursor + +# Lines class. +# list-like object for access text document lines + + +def _iterateBlocksFrom(block): + while block.isValid(): + yield block + block = block.next() + + +def _atomicModification(func): + """Decorator + Make document modification atomic + """ + def wrapper(*args, **kwargs): + self = args[0] + with self._qpart: # pylint: disable=protected-access + func(*args, **kwargs) + return wrapper + + +class Lines: + """list-like object for access text document lines + """ + def __init__(self, qpart): + self._qpart = qpart + self._doc = qpart.document() + + def setDocument(self, document): + self._doc = document + + def _toList(self): + """Convert to Python list + """ + return [block.text() \ + for block in _iterateBlocksFrom(self._doc.firstBlock())] + + def __str__(self): + """Serialize + """ + return str(self._toList()) + + def __len__(self): + """Get lines count + """ + return self._doc.blockCount() + + def _checkAndConvertIndex(self, index): + """Check integer index, convert from less than zero notation + """ + if index < 0: + index = len(self) + index + if index < 0 or index >= self._doc.blockCount(): + raise IndexError('Invalid block index', index) + return index + + def __getitem__(self, index): + """Get item by index + """ + def _getTextByIndex(blockIndex): + return self._doc.findBlockByNumber(blockIndex).text() + + if isinstance(index, int): + index = self._checkAndConvertIndex(index) + return _getTextByIndex(index) + elif isinstance(index, slice): + start, stop, step = index.indices(self._doc.blockCount()) + return [_getTextByIndex(blockIndex) \ + for blockIndex in range(start, stop, step)] + + @_atomicModification + def __setitem__(self, index, value): + """Set item by index + """ + def _setBlockText(blockIndex, text): + cursor = QTextCursor(self._doc.findBlockByNumber(blockIndex)) + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + cursor.insertText(text) + + if isinstance(index, int): + index = self._checkAndConvertIndex(index) + _setBlockText(index, value) + elif isinstance(index, slice): + # List of indexes is reversed for make sure + # not processed indexes are not shifted during document modification + start, stop, step = index.indices(self._doc.blockCount()) + if step > 0: + start, stop, step = stop - 1, start - 1, step * -1 + + blockIndexes = list(range(start, stop, step)) + + if len(blockIndexes) != len(value): + raise ValueError('Attempt to replace %d lines with %d lines' % + (len(blockIndexes), len(value))) + + for blockIndex, text in zip(blockIndexes, value[::-1]): + _setBlockText(blockIndex, text) + + @_atomicModification + def __delitem__(self, index): + """Delete item by index + """ + def _removeBlock(blockIndex): + block = self._doc.findBlockByNumber(blockIndex) + if block.next().isValid(): # not the last + cursor = QTextCursor(block) + cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) + elif block.previous().isValid(): # the last, not the first + cursor = QTextCursor(block.previous()) + cursor.movePosition(QTextCursor.EndOfBlock) + cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + else: # only one block + cursor = QTextCursor(block) + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + cursor.removeSelectedText() + + if isinstance(index, int): + index = self._checkAndConvertIndex(index) + _removeBlock(index) + elif isinstance(index, slice): + # List of indexes is reversed for make sure + # not processed indexes are not shifted during document modification + start, stop, step = index.indices(self._doc.blockCount()) + if step > 0: + start, stop, step = stop - 1, start - 1, step * -1 + + for blockIndex in range(start, stop, step): + _removeBlock(blockIndex) + + class _Iterator: + """Blocks iterator. Returns text + """ + def __init__(self, block): + self._block = block + + def __iter__(self): + return self + + def __next__(self): + if self._block.isValid(): + self._block, result = self._block.next(), self._block.text() + return result + else: + raise StopIteration() + + def __iter__(self): + """Return iterator object + """ + return self._Iterator(self._doc.firstBlock()) + + @_atomicModification + def append(self, text): + """Append line to the end + """ + cursor = QTextCursor(self._doc) + cursor.movePosition(QTextCursor.End) + cursor.insertBlock() + cursor.insertText(text) + + @_atomicModification + def insert(self, index, text): + """Insert line to the document + """ + if index < 0 or index > self._doc.blockCount(): + raise IndexError('Invalid block index', index) + + if index == 0: # first + cursor = QTextCursor(self._doc.firstBlock()) + cursor.insertText(text) + cursor.insertBlock() + elif index != self._doc.blockCount(): # not the last + cursor = QTextCursor(self._doc.findBlockByNumber(index).previous()) + cursor.movePosition(QTextCursor.EndOfBlock) + cursor.insertBlock() + cursor.insertText(text) + else: # last append to the end + self.append(text) diff --git a/Orange/widgets/data/utils/pythoneditor/rectangularselection.py b/Orange/widgets/data/utils/pythoneditor/rectangularselection.py new file mode 100644 index 00000000000..8dcc70eff54 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/rectangularselection.py @@ -0,0 +1,263 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" +from PyQt5.QtCore import Qt, QMimeData +from PyQt5.QtWidgets import QApplication, QTextEdit +from PyQt5.QtGui import QKeyEvent, QKeySequence, QPalette, QTextCursor + + +class RectangularSelection: + """This class does not replresent any object, but is part of Qutepart + It just groups together Qutepart rectangular selection methods and fields + """ + + MIME_TYPE = 'text/rectangular-selection' + + # any of this modifiers with mouse select text + MOUSE_MODIFIERS = (Qt.AltModifier | Qt.ControlModifier, + Qt.AltModifier | Qt.ShiftModifier, + Qt.AltModifier) + + _MAX_SIZE = 256 + + def __init__(self, qpart): + self._qpart = qpart + self._start = None + + qpart.cursorPositionChanged.connect(self._reset) # disconnected during Alt+Shift+... + qpart.textChanged.connect(self._reset) + qpart.selectionChanged.connect(self._reset) # disconnected during Alt+Shift+... + + def _reset(self): + """Cursor moved while Alt is not pressed, or text modified. + Reset rectangular selection""" + if self._start is not None: + self._start = None + self._qpart._updateExtraSelections() # pylint: disable=protected-access + + def isDeleteKeyEvent(self, keyEvent): + """Check if key event should be handled as Delete command""" + return self._start is not None and \ + (keyEvent.matches(QKeySequence.Delete) or \ + (keyEvent.key() == Qt.Key_Backspace and keyEvent.modifiers() == Qt.NoModifier)) + + def delete(self): + """Del or Backspace pressed. Delete selection""" + with self._qpart: + for cursor in self.cursors(): + if cursor.hasSelection(): + cursor.deleteChar() + + @staticmethod + def isExpandKeyEvent(keyEvent): + """Check if key event should expand rectangular selection""" + return keyEvent.modifiers() & Qt.ShiftModifier and \ + keyEvent.modifiers() & Qt.AltModifier and \ + keyEvent.key() in (Qt.Key_Left, Qt.Key_Right, Qt.Key_Down, Qt.Key_Up, + Qt.Key_PageUp, Qt.Key_PageDown, Qt.Key_Home, Qt.Key_End) + + def onExpandKeyEvent(self, keyEvent): + """One of expand selection key events""" + if self._start is None: + currentBlockText = self._qpart.textCursor().block().text() + line = self._qpart.cursorPosition[0] + visibleColumn = self._realToVisibleColumn(currentBlockText, + self._qpart.cursorPosition[1]) + self._start = (line, visibleColumn) + modifiersWithoutAltShift = keyEvent.modifiers() & (~(Qt.AltModifier | Qt.ShiftModifier)) + newEvent = QKeyEvent(keyEvent.type(), + keyEvent.key(), + modifiersWithoutAltShift, + keyEvent.text(), + keyEvent.isAutoRepeat(), + keyEvent.count()) + + self._qpart.cursorPositionChanged.disconnect(self._reset) + self._qpart.selectionChanged.disconnect(self._reset) + super(self._qpart.__class__, self._qpart).keyPressEvent(newEvent) + self._qpart.cursorPositionChanged.connect(self._reset) + self._qpart.selectionChanged.connect(self._reset) + # extra selections will be updated, because cursor has been moved + + def _visibleCharPositionGenerator(self, text): + currentPos = 0 + yield currentPos + + for char in text: + if char == '\t': + currentPos += self._qpart.indentWidth + # trim reminder. If width('\t') == 4, width('abc\t') == 4 + currentPos = currentPos // self._qpart.indentWidth * self._qpart.indentWidth + else: + currentPos += 1 + yield currentPos + + def _realToVisibleColumn(self, text, realColumn): + """If \t is used, real position of symbol in block and visible position differs + This function converts real to visible + """ + generator = self._visibleCharPositionGenerator(text) + for _ in range(realColumn): + val = next(generator) + val = next(generator) + return val + + def _visibleToRealColumn(self, text, visiblePos): + """If \t is used, real position of symbol in block and visible position differs + This function converts visible to real. + Bigger value is returned, if visiblePos is in the middle of \t, None if text is too short + """ + if visiblePos == 0: + return 0 + elif not '\t' in text: + return visiblePos + else: + currentIndex = 1 + for currentVisiblePos in self._visibleCharPositionGenerator(text): + if currentVisiblePos >= visiblePos: + return currentIndex - 1 + currentIndex += 1 + + return None + + def cursors(self): + """Cursors for rectangular selection. + 1 cursor for every line + """ + cursors = [] + if self._start is not None: + startLine, startVisibleCol = self._start + currentLine, currentCol = self._qpart.cursorPosition + if abs(startLine - currentLine) > self._MAX_SIZE or \ + abs(startVisibleCol - currentCol) > self._MAX_SIZE: + # Too big rectangular selection freezes the GUI + self._qpart.userWarning.emit('Rectangular selection area is too big') + self._start = None + return [] + + currentBlockText = self._qpart.textCursor().block().text() + currentVisibleCol = self._realToVisibleColumn(currentBlockText, currentCol) + + for lineNumber in range(min(startLine, currentLine), + max(startLine, currentLine) + 1): + block = self._qpart.document().findBlockByNumber(lineNumber) + cursor = QTextCursor(block) + realStartCol = self._visibleToRealColumn(block.text(), startVisibleCol) + realCurrentCol = self._visibleToRealColumn(block.text(), currentVisibleCol) + if realStartCol is None: + realStartCol = block.length() # out of range value + if realCurrentCol is None: + realCurrentCol = block.length() # out of range value + + cursor.setPosition(cursor.block().position() + + min(realStartCol, block.length() - 1)) + cursor.setPosition(cursor.block().position() + + min(realCurrentCol, block.length() - 1), + QTextCursor.KeepAnchor) + cursors.append(cursor) + + return cursors + + def selections(self): + """Build list of extra selections for rectangular selection""" + selections = [] + cursors = self.cursors() + if cursors: + background = self._qpart.palette().color(QPalette.Highlight) + foreground = self._qpart.palette().color(QPalette.HighlightedText) + for cursor in cursors: + selection = QTextEdit.ExtraSelection() + selection.format.setBackground(background) + selection.format.setForeground(foreground) + selection.cursor = cursor + + selections.append(selection) + + return selections + + def isActive(self): + """Some rectangle is selected""" + return self._start is not None + + def copy(self): + """Copy to the clipboard""" + data = QMimeData() + text = '\n'.join([cursor.selectedText() \ + for cursor in self.cursors()]) + data.setText(text) + data.setData(self.MIME_TYPE, text.encode('utf8')) + QApplication.clipboard().setMimeData(data) + + def cut(self): + """Cut action. Copy and delete + """ + cursorPos = self._qpart.cursorPosition + topLeft = (min(self._start[0], cursorPos[0]), + min(self._start[1], cursorPos[1])) + self.copy() + self.delete() + + # Move cursor to top-left corner of the selection, + # so that if text gets pasted again, original text will be restored + self._qpart.cursorPosition = topLeft + + def _indentUpTo(self, text, width): + """Add space to text, so text width will be at least width. + Return text, which must be added + """ + visibleTextWidth = self._realToVisibleColumn(text, len(text)) + diff = width - visibleTextWidth + if diff <= 0: + return '' + elif self._qpart.indentUseTabs and \ + all(char == '\t' for char in text): # if using tabs and only tabs in text + return '\t' * (diff // self._qpart.indentWidth) + \ + ' ' * (diff % self._qpart.indentWidth) + else: + return ' ' * int(diff) + + def paste(self, mimeData): + """Paste recrangular selection. + Add space at the beginning of line, if necessary + """ + if self.isActive(): + self.delete() + elif self._qpart.textCursor().hasSelection(): + self._qpart.textCursor().deleteChar() + + text = bytes(mimeData.data(self.MIME_TYPE)).decode('utf8') + lines = text.splitlines() + cursorLine, cursorCol = self._qpart.cursorPosition + if cursorLine + len(lines) > len(self._qpart.lines): + for _ in range(cursorLine + len(lines) - len(self._qpart.lines)): + self._qpart.lines.append('') + + with self._qpart: + for index, line in enumerate(lines): + currentLine = self._qpart.lines[cursorLine + index] + newLine = currentLine[:cursorCol] + \ + self._indentUpTo(currentLine, cursorCol) + \ + line + \ + currentLine[cursorCol:] + self._qpart.lines[cursorLine + index] = newLine + self._qpart.cursorPosition = cursorLine, cursorCol + + def mousePressEvent(self, mouseEvent): + cursor = self._qpart.cursorForPosition(mouseEvent.pos()) + self._start = cursor.block().blockNumber(), cursor.positionInBlock() + + def mouseMoveEvent(self, mouseEvent): + cursor = self._qpart.cursorForPosition(mouseEvent.pos()) + + self._qpart.cursorPositionChanged.disconnect(self._reset) + self._qpart.selectionChanged.disconnect(self._reset) + self._qpart.setTextCursor(cursor) + self._qpart.cursorPositionChanged.connect(self._reset) + self._qpart.selectionChanged.connect(self._reset) + # extra selections will be updated, because cursor has been moved diff --git a/Orange/widgets/data/utils/pythoneditor/tests/__init__.py b/Orange/widgets/data/utils/pythoneditor/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Orange/widgets/data/utils/pythoneditor/tests/base.py b/Orange/widgets/data/utils/pythoneditor/tests/base.py new file mode 100644 index 00000000000..1fd3d4aaec5 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/base.py @@ -0,0 +1,79 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import time + +from AnyQt.QtCore import QTimer +from AnyQt.QtGui import QKeySequence +from AnyQt.QtTest import QTest +from AnyQt.QtCore import Qt, QCoreApplication + +from Orange.widgets import widget +from Orange.widgets.data.utils.pythoneditor.editor import PythonEditor + + +def _processPendingEvents(app): + """Process pending application events. + Timeout is used, because on Windows hasPendingEvents() always returns True + """ + t = time.time() + while app.hasPendingEvents() and (time.time() - t < 0.1): + app.processEvents() + + +def in_main_loop(func, *_): + """Decorator executes test method in the QApplication main loop. + QAction shortcuts doesn't work, if main loop is not running. + Do not use for tests, which doesn't use main loop, because it slows down execution. + """ + def wrapper(*args): + app = QCoreApplication.instance() + self = args[0] + + def execWithArgs(): + self.qpart.show() + QTest.qWaitForWindowExposed(self.qpart) + _processPendingEvents(app) + + try: + func(*args) + finally: + _processPendingEvents(app) + app.quit() + + QTimer.singleShot(0, execWithArgs) + + app.exec_() + + wrapper.__name__ = func.__name__ # for unittest test runner + return wrapper + +class SimpleWidget(widget.OWWidget): + name = "Simple widget" + + def __init__(self): + super().__init__() + self.qpart = PythonEditor(self) + self.mainArea.layout().addWidget(self.qpart) + + +def keySequenceClicks(widget_, keySequence, extraModifiers=Qt.NoModifier): + """Use QTest.keyClick to send a QKeySequence to a widget.""" + # pylint: disable=line-too-long + # This is based on a simplified version of http://stackoverflow.com/questions/14034209/convert-string-representation-of-keycode-to-qtkey-or-any-int-and-back. I added code to handle the case in which the resulting key contains a modifier (for example, Shift+Home). When I execute QTest.keyClick(widget, keyWithModifier), I get the error "ASSERT: "false" in file .\qasciikey.cpp, line 495". To fix this, the following code splits the key into a key and its modifier. + # Bitmask for all modifier keys. + modifierMask = int(Qt.ShiftModifier | Qt.ControlModifier | Qt.AltModifier | + Qt.MetaModifier | Qt.KeypadModifier) + ks = QKeySequence(keySequence) + # For now, we don't handle a QKeySequence("Ctrl") or any other modified by itself. + assert ks.count() > 0 + for _, key in enumerate(ks): + modifiers = Qt.KeyboardModifiers((key & modifierMask) | extraModifiers) + key = key & ~modifierMask + QTest.keyClick(widget_, key, modifiers, 10) diff --git a/Orange/widgets/data/utils/pythoneditor/tests/run_all.py b/Orange/widgets/data/utils/pythoneditor/tests/run_all.py new file mode 100644 index 00000000000..016bc0caec7 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/run_all.py @@ -0,0 +1,27 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import unittest +import sys + +if __name__ == "__main__": + # Look for all tests. Using test_* instead of + # test_*.py finds modules (test_syntax and test_indenter). + suite = unittest.TestLoader().discover('.', pattern="test_*") + print("Suite created") + result = unittest.TextTestRunner(verbosity=2).run(suite) + print("Run done") + + # Indicate success or failure via the exit code: success = 0, failure = 1. + if result.wasSuccessful(): + print("OK") + sys.exit(0) + else: + print("Failed") + sys.exit(not result.wasSuccessful()) diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_api.py b/Orange/widgets/data/utils/pythoneditor/tests/test_api.py new file mode 100755 index 00000000000..64e57635993 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_api.py @@ -0,0 +1,281 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import unittest + +from Orange.widgets.data.utils.pythoneditor.tests.base import SimpleWidget +from Orange.widgets.tests.base import WidgetTest + +# pylint: disable=protected-access + +class _BaseTest(WidgetTest): + """Base class for tests + """ + + def setUp(self): + self.widget = self.create_widget(SimpleWidget) + self.qpart = self.widget.qpart + + def tearDown(self): + self.qpart.terminate() + + +class Selection(_BaseTest): + + def test_resetSelection(self): + # Reset selection + self.qpart.text = 'asdf fdsa' + self.qpart.absSelectedPosition = 1, 3 + self.assertTrue(self.qpart.textCursor().hasSelection()) + self.qpart.resetSelection() + self.assertFalse(self.qpart.textCursor().hasSelection()) + + def test_setSelection(self): + self.qpart.text = 'asdf fdsa' + + self.qpart.selectedPosition = ((0, 3), (0, 7)) + + self.assertEqual(self.qpart.selectedText, "f fd") + self.assertEqual(self.qpart.selectedPosition, ((0, 3), (0, 7))) + + def test_selected_multiline_text(self): + self.qpart.text = "a\nb" + self.qpart.selectedPosition = ((0, 0), (1, 1)) + self.assertEqual(self.qpart.selectedText, "a\nb") + + +class ReplaceText(_BaseTest): + def test_replaceText1(self): + # Basic case + self.qpart.text = '123456789' + self.qpart.replaceText(3, 4, 'xyz') + self.assertEqual(self.qpart.text, '123xyz89') + + def test_replaceText2(self): + # Replace uses (line, col) position + self.qpart.text = '12345\n67890\nabcde' + self.qpart.replaceText((1, 4), 3, 'Z') + self.assertEqual(self.qpart.text, '12345\n6789Zbcde') + + def test_replaceText3(self): + # Edge cases + self.qpart.text = '12345\n67890\nabcde' + self.qpart.replaceText((0, 0), 3, 'Z') + self.assertEqual(self.qpart.text, 'Z45\n67890\nabcde') + + self.qpart.text = '12345\n67890\nabcde' + self.qpart.replaceText((2, 4), 1, 'Z') + self.assertEqual(self.qpart.text, '12345\n67890\nabcdZ') + + self.qpart.text = '12345\n67890\nabcde' + self.qpart.replaceText((0, 0), 0, 'Z') + self.assertEqual(self.qpart.text, 'Z12345\n67890\nabcde') + + self.qpart.text = '12345\n67890\nabcde' + self.qpart.replaceText((2, 5), 0, 'Z') + self.assertEqual(self.qpart.text, '12345\n67890\nabcdeZ') + + def test_replaceText4(self): + # Replace nothing with something + self.qpart.text = '12345\n67890\nabcde' + self.qpart.replaceText(2, 0, 'XYZ') + self.assertEqual(self.qpart.text, '12XYZ345\n67890\nabcde') + + def test_replaceText5(self): + # Make sure exceptions are raised for invalid params + self.qpart.text = '12345\n67890\nabcde' + self.assertRaises(IndexError, self.qpart.replaceText, -1, 1, 'Z') + self.assertRaises(IndexError, self.qpart.replaceText, len(self.qpart.text) + 1, 0, 'Z') + self.assertRaises(IndexError, self.qpart.replaceText, len(self.qpart.text), 1, 'Z') + self.assertRaises(IndexError, self.qpart.replaceText, (0, 7), 1, 'Z') + self.assertRaises(IndexError, self.qpart.replaceText, (7, 0), 1, 'Z') + + +class InsertText(_BaseTest): + def test_1(self): + # Basic case + self.qpart.text = '123456789' + self.qpart.insertText(3, 'xyz') + self.assertEqual(self.qpart.text, '123xyz456789') + + def test_2(self): + # (line, col) position + self.qpart.text = '12345\n67890\nabcde' + self.qpart.insertText((1, 4), 'Z') + self.assertEqual(self.qpart.text, '12345\n6789Z0\nabcde') + + def test_3(self): + # Edge cases + self.qpart.text = '12345\n67890\nabcde' + self.qpart.insertText((0, 0), 'Z') + self.assertEqual(self.qpart.text, 'Z12345\n67890\nabcde') + + self.qpart.text = '12345\n67890\nabcde' + self.qpart.insertText((2, 5), 'Z') + self.assertEqual(self.qpart.text, '12345\n67890\nabcdeZ') + + +class IsCodeOrComment(_BaseTest): + def test_1(self): + # Basic case + self.qpart.text = 'a + b # comment' + self.assertEqual([self.qpart.isCode(0, i) for i in range(len(self.qpart.text))], + [True, True, True, True, True, True, False, False, False, False, + False, False, False, False, False]) + self.assertEqual([self.qpart.isComment(0, i) for i in range(len(self.qpart.text))], + [False, False, False, False, False, False, True, True, True, True, + True, True, True, True, True]) + + def test_2(self): + self.qpart.text = '#' + + self.assertFalse(self.qpart.isCode(0, 0)) + self.assertTrue(self.qpart.isComment(0, 0)) + + +class ToggleCommentTest(_BaseTest): + def test_single_line(self): + self.qpart.text = 'a = 2' + self.qpart._onToggleCommentLine() + self.assertEqual('# a = 2\n', self.qpart.text) + self.qpart._onToggleCommentLine() + self.assertEqual('# a = 2\n', self.qpart.text) + self.qpart._selectLines(0, 0) + self.qpart._onToggleCommentLine() + self.assertEqual('a = 2\n', self.qpart.text) + + def test_two_lines(self): + self.qpart.text = 'a = 2\nb = 3' + self.qpart._selectLines(0, 1) + self.qpart._onToggleCommentLine() + self.assertEqual('# a = 2\n# b = 3\n', self.qpart.text) + self.qpart.undo() + self.assertEqual('a = 2\nb = 3', self.qpart.text) + + +class Signals(_BaseTest): + def test_indent_width_changed(self): + newValue = [None] + + def setNeVal(val): + newValue[0] = val + + self.qpart.indentWidthChanged.connect(setNeVal) + + self.qpart.indentWidth = 7 + self.assertEqual(newValue[0], 7) + + def test_use_tabs_changed(self): + newValue = [None] + + def setNeVal(val): + newValue[0] = val + + self.qpart.indentUseTabsChanged.connect(setNeVal) + + self.qpart.indentUseTabs = True + self.assertEqual(newValue[0], True) + + def test_eol_changed(self): + newValue = [None] + + def setNeVal(val): + newValue[0] = val + + self.qpart.eolChanged.connect(setNeVal) + + self.qpart.eol = '\r\n' + self.assertEqual(newValue[0], '\r\n') + + +class Lines(_BaseTest): + def setUp(self): + super().setUp() + self.qpart.text = 'abcd\nefgh\nklmn\nopqr' + + def test_accessByIndex(self): + self.assertEqual(self.qpart.lines[0], 'abcd') + self.assertEqual(self.qpart.lines[1], 'efgh') + self.assertEqual(self.qpart.lines[-1], 'opqr') + + def test_modifyByIndex(self): + self.qpart.lines[2] = 'new text' + self.assertEqual(self.qpart.text, 'abcd\nefgh\nnew text\nopqr') + + def test_getSlice(self): + self.assertEqual(self.qpart.lines[0], 'abcd') + self.assertEqual(self.qpart.lines[1], 'efgh') + self.assertEqual(self.qpart.lines[3], 'opqr') + self.assertEqual(self.qpart.lines[-4], 'abcd') + self.assertEqual(self.qpart.lines[1:4], ['efgh', 'klmn', 'opqr']) + self.assertEqual(self.qpart.lines[1:7], + ['efgh', 'klmn', 'opqr']) # Python list behaves this way + self.assertEqual(self.qpart.lines[0:0], []) + self.assertEqual(self.qpart.lines[0:1], ['abcd']) + self.assertEqual(self.qpart.lines[:2], ['abcd', 'efgh']) + self.assertEqual(self.qpart.lines[0:-2], ['abcd', 'efgh']) + self.assertEqual(self.qpart.lines[-2:], ['klmn', 'opqr']) + self.assertEqual(self.qpart.lines[-4:-2], ['abcd', 'efgh']) + + with self.assertRaises(IndexError): + self.qpart.lines[4] # pylint: disable=pointless-statement + with self.assertRaises(IndexError): + self.qpart.lines[-5] # pylint: disable=pointless-statement + + def test_setSlice_1(self): + self.qpart.lines[0] = 'xyz' + self.assertEqual(self.qpart.text, 'xyz\nefgh\nklmn\nopqr') + + def test_setSlice_2(self): + self.qpart.lines[1] = 'xyz' + self.assertEqual(self.qpart.text, 'abcd\nxyz\nklmn\nopqr') + + def test_setSlice_3(self): + self.qpart.lines[-4] = 'xyz' + self.assertEqual(self.qpart.text, 'xyz\nefgh\nklmn\nopqr') + + def test_setSlice_4(self): + self.qpart.lines[0:4] = ['st', 'uv', 'wx', 'z'] + self.assertEqual(self.qpart.text, 'st\nuv\nwx\nz') + + def test_setSlice_5(self): + self.qpart.lines[0:47] = ['st', 'uv', 'wx', 'z'] + self.assertEqual(self.qpart.text, 'st\nuv\nwx\nz') + + def test_setSlice_6(self): + self.qpart.lines[1:3] = ['st', 'uv'] + self.assertEqual(self.qpart.text, 'abcd\nst\nuv\nopqr') + + def test_setSlice_61(self): + with self.assertRaises(ValueError): + self.qpart.lines[1:3] = ['st', 'uv', 'wx', 'z'] + + def test_setSlice_7(self): + self.qpart.lines[-3:3] = ['st', 'uv'] + self.assertEqual(self.qpart.text, 'abcd\nst\nuv\nopqr') + + def test_setSlice_8(self): + self.qpart.lines[-3:-1] = ['st', 'uv'] + self.assertEqual(self.qpart.text, 'abcd\nst\nuv\nopqr') + + def test_setSlice_9(self): + with self.assertRaises(IndexError): + self.qpart.lines[4] = 'st' + with self.assertRaises(IndexError): + self.qpart.lines[-5] = 'st' + + +class LinesWin(Lines): + def setUp(self): + super().setUp() + self.qpart.eol = '\r\n' + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_bracket_highlighter.py b/Orange/widgets/data/utils/pythoneditor/tests/test_bracket_highlighter.py new file mode 100755 index 00000000000..6a3d5f6761a --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_bracket_highlighter.py @@ -0,0 +1,71 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import unittest + +from Orange.widgets.data.utils.pythoneditor.brackethighlighter import BracketHighlighter +from Orange.widgets.data.utils.pythoneditor.tests.base import SimpleWidget +from Orange.widgets.tests.base import WidgetTest + + +class Test(WidgetTest): + """Base class for tests + """ + + def setUp(self): + self.widget = self.create_widget(SimpleWidget) + self.qpart = self.widget.qpart + + def tearDown(self): + self.qpart.terminate() + + def _verify(self, actual, expected): + converted = [] + for item in actual: + if item.format.foreground().color() == BracketHighlighter.MATCHED_COLOR: + matched = True + elif item.format.foreground().color() == BracketHighlighter.UNMATCHED_COLOR: + matched = False + else: + self.fail("Invalid color") + start = item.cursor.selectionStart() + end = item.cursor.selectionEnd() + converted.append((start, end, matched)) + + self.assertEqual(converted, expected) + + def test_1(self): + self.qpart.lines = \ + ['func(param,', + ' "text ( param"))'] + + firstBlock = self.qpart.document().firstBlock() + secondBlock = firstBlock.next() + + bh = BracketHighlighter() + + self._verify(bh.extraSelections(self.qpart, firstBlock, 1), + []) + + self._verify(bh.extraSelections(self.qpart, firstBlock, 4), + [(4, 5, True), (31, 32, True)]) + self._verify(bh.extraSelections(self.qpart, firstBlock, 5), + [(4, 5, True), (31, 32, True)]) + self._verify(bh.extraSelections(self.qpart, secondBlock, 11), + []) + self._verify(bh.extraSelections(self.qpart, secondBlock, 19), + [(31, 32, True), (4, 5, True)]) + self._verify(bh.extraSelections(self.qpart, secondBlock, 20), + [(32, 33, False)]) + self._verify(bh.extraSelections(self.qpart, secondBlock, 21), + [(32, 33, False)]) + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_draw_whitespace.py b/Orange/widgets/data/utils/pythoneditor/tests/test_draw_whitespace.py new file mode 100755 index 00000000000..7c5c4adb032 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_draw_whitespace.py @@ -0,0 +1,102 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import unittest + +from Orange.widgets.data.utils.pythoneditor.tests.base import SimpleWidget +from Orange.widgets.tests.base import WidgetTest + + +class Test(WidgetTest): + """Base class for tests + """ + + def setUp(self): + self.widget = self.create_widget(SimpleWidget) + self.qpart = self.widget.qpart + + def tearDown(self): + self.qpart.terminate() + + def _ws_test(self, + text, + expectedResult, + drawAny=None, + drawIncorrect=None, + useTab=None, + indentWidth=None): + if drawAny is None: + drawAny = [True, False] + if drawIncorrect is None: + drawIncorrect = [True, False] + if useTab is None: + useTab = [True, False] + if indentWidth is None: + indentWidth = [1, 2, 3, 4, 8] + for drawAnyVal in drawAny: + self.qpart.drawAnyWhitespace = drawAnyVal + + for drawIncorrectVal in drawIncorrect: + self.qpart.drawIncorrectIndentation = drawIncorrectVal + + for useTabVal in useTab: + self.qpart.indentUseTabs = useTabVal + + for indentWidthVal in indentWidth: + self.qpart.indentWidth = indentWidthVal + try: + self._verify(text, expectedResult) + except: + print("Failed params:\n\tany {}\n\tincorrect {}\n\ttabs {}\n\twidth {}" + .format(self.qpart.drawAnyWhitespace, + self.qpart.drawIncorrectIndentation, + self.qpart.indentUseTabs, + self.qpart.indentWidth)) + raise + + def _verify(self, text, expectedResult): + res = self.qpart._chooseVisibleWhitespace(text) # pylint: disable=protected-access + for index, value in enumerate(expectedResult): + if value == '1': + if not res[index]: + self.fail("Item {} is not True:\n\t{}".format(index, res)) + elif value == '0': + if res[index]: + self.fail("Item {} is not False:\n\t{}".format(index, res)) + else: + assert value == ' ' + + def test_1(self): + # Trailing + self._ws_test(' m xyz\t ', + ' 0 00011', + drawIncorrect=[True]) + + def test_2(self): + # Tabs in space mode + self._ws_test('\txyz\t', + '10001', + drawIncorrect=[True], useTab=[False]) + + def test_3(self): + # Spaces in tab mode + self._ws_test(' 2 3 5', + '111100000000000', + drawIncorrect=[True], drawAny=[False], indentWidth=[3], useTab=[True]) + + def test_4(self): + # Draw any + self._ws_test(' 1 1 2 3 5\t', + '100011011101111101', + drawAny=[True], + indentWidth=[2, 3, 4, 8]) + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_edit.py b/Orange/widgets/data/utils/pythoneditor/tests/test_edit.py new file mode 100755 index 00000000000..cd342a6ce28 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_edit.py @@ -0,0 +1,111 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import unittest + +from AnyQt.QtCore import Qt +from AnyQt.QtGui import QKeySequence +from AnyQt.QtTest import QTest + +from Orange.widgets.data.utils.pythoneditor.tests import base +from Orange.widgets.data.utils.pythoneditor.tests.base import SimpleWidget +from Orange.widgets.tests.base import WidgetTest + + +class Test(WidgetTest): + """Base class for tests + """ + def setUp(self): + self.widget = self.create_widget(SimpleWidget) + self.qpart = self.widget.qpart + + def tearDown(self): + self.qpart.terminate() + + def test_overwrite_edit(self): + self.qpart.show() + self.qpart.text = 'abcd' + QTest.keyClicks(self.qpart, "stu") + self.assertEqual(self.qpart.text, 'stuabcd') + QTest.keyClick(self.qpart, Qt.Key_Insert) + QTest.keyClicks(self.qpart, "xy") + self.assertEqual(self.qpart.text, 'stuxycd') + QTest.keyClick(self.qpart, Qt.Key_Insert) + QTest.keyClicks(self.qpart, "z") + self.assertEqual(self.qpart.text, 'stuxyzcd') + + def test_overwrite_backspace(self): + self.qpart.show() + self.qpart.text = 'abcd' + QTest.keyClick(self.qpart, Qt.Key_Insert) + for _ in range(3): + QTest.keyClick(self.qpart, Qt.Key_Right) + for _ in range(2): + QTest.keyClick(self.qpart, Qt.Key_Backspace) + self.assertEqual(self.qpart.text, 'a d') + + @base.in_main_loop + def test_overwrite_undo(self): + self.qpart.show() + self.qpart.text = 'abcd' + QTest.keyClick(self.qpart, Qt.Key_Insert) + QTest.keyClick(self.qpart, Qt.Key_Right) + QTest.keyClick(self.qpart, Qt.Key_X) + QTest.keyClick(self.qpart, Qt.Key_X) + self.assertEqual(self.qpart.text, 'axxd') + # Ctrl+Z doesn't work. Wtf??? + self.qpart.document().undo() + self.qpart.document().undo() + self.assertEqual(self.qpart.text, 'abcd') + + def test_home1(self): + """ Test the operation of the home key. """ + + self.qpart.show() + self.qpart.text = ' xx' + # Move to the end of this string. + self.qpart.cursorPosition = (100, 100) + # Press home the first time. This should move to the beginning of the + # indent: line 0, column 4. + self.assertEqual(self.qpart.cursorPosition, (0, 4)) + + def column(self): + """ Return the column at which the cursor is located.""" + return self.qpart.cursorPosition[1] + + def test_home2(self): + """ Test the operation of the home key. """ + + self.qpart.show() + self.qpart.text = '\n\n ' + 'x'*10000 + # Move to the end of this string. + self.qpart.cursorPosition = (100, 100) + # Press home. We should either move to the line beginning or indent. Use + # a QKeySequence because there's no home key on some Macs, so use + # whatever means home on that platform. + base.keySequenceClicks(self.qpart, QKeySequence.MoveToStartOfLine) + # There's no way I can find of determine what the line beginning should + # be. So, just press home again if we're not at the indent. + if self.column() != 4: + # Press home again to move to the beginning of the indent. + base.keySequenceClicks(self.qpart, QKeySequence.MoveToStartOfLine) + # We're at the indent. + self.assertEqual(self.column(), 4) + + # Move to the beginning of the line. + base.keySequenceClicks(self.qpart, QKeySequence.MoveToStartOfLine) + self.assertEqual(self.column(), 0) + + # Move back to the beginning of the indent. + base.keySequenceClicks(self.qpart, QKeySequence.MoveToStartOfLine) + self.assertEqual(self.column(), 4) + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_indent.py b/Orange/widgets/data/utils/pythoneditor/tests/test_indent.py new file mode 100755 index 00000000000..019df7ebd08 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_indent.py @@ -0,0 +1,140 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import unittest + +from AnyQt.QtCore import Qt +from AnyQt.QtTest import QTest + +from Orange.widgets.data.utils.pythoneditor.tests.base import SimpleWidget +from Orange.widgets.tests.base import WidgetTest + + +class Test(WidgetTest): + """Base class for tests + """ + + def setUp(self): + self.widget = self.create_widget(SimpleWidget) + self.qpart = self.widget.qpart + + def tearDown(self): + self.qpart.terminate() + + def test_1(self): + # Indent with Tab + self.qpart.indentUseTabs = True + self.qpart.text = 'ab\ncd' + QTest.keyClick(self.qpart, Qt.Key_Down) + QTest.keyClick(self.qpart, Qt.Key_Tab) + self.assertEqual(self.qpart.text, 'ab\n\tcd') + + self.qpart.indentUseTabs = False + QTest.keyClick(self.qpart, Qt.Key_Backspace) + QTest.keyClick(self.qpart, Qt.Key_Tab) + self.assertEqual(self.qpart.text, 'ab\n cd') + + def test_2(self): + # Unindent Tab + self.qpart.indentUseTabs = True + self.qpart.text = 'ab\n\t\tcd' + self.qpart.cursorPosition = (1, 2) + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, 'ab\n\tcd') + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, 'ab\ncd') + + def test_3(self): + # Unindent Spaces + self.qpart.indentUseTabs = False + + self.qpart.text = 'ab\n cd' + self.qpart.cursorPosition = (1, 6) + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, 'ab\n cd') + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, 'ab\ncd') + + def test_4(self): + # (Un)indent multiline with Tab + self.qpart.indentUseTabs = False + + self.qpart.text = ' ab\n cd' + self.qpart.selectedPosition = ((0, 2), (1, 3)) + + QTest.keyClick(self.qpart, Qt.Key_Tab) + self.assertEqual(self.qpart.text, ' ab\n cd') + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, ' ab\n cd') + + def test_4b(self): + # Indent multiline including line with zero selection + self.qpart.indentUseTabs = True + + self.qpart.text = 'ab\ncd\nef' + self.qpart.position = (0, 0) + + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Tab) + self.assertEqual(self.qpart.text, '\tab\ncd\nef') + + @unittest.skip # Fantom crashes happen when running multiple tests. TODO find why + def test_5(self): + # (Un)indent multiline with Space + self.qpart.indentUseTabs = False + + self.qpart.text = ' ab\n cd' + self.qpart.selectedPosition = ((0, 2), (1, 3)) + + QTest.keyClick(self.qpart, Qt.Key_Space, Qt.ShiftModifier | Qt.ControlModifier) + self.assertEqual(self.qpart.text, ' ab\n cd') + + QTest.keyClick(self.qpart, Qt.Key_Backspace, Qt.ShiftModifier | Qt.ControlModifier) + self.assertEqual(self.qpart.text, ' ab\n cd') + + def test_6(self): + # (Unindent Tab/Space mix + self.qpart.indentUseTabs = False + + self.qpart.text = ' \t \tab' + self.qpart.cursorPosition = ((0, 8)) + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, ' \t ab') + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, ' \tab') + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, ' ab') + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, 'ab') + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, 'ab') + + def test_7(self): + """Smartly indent python""" + QTest.keyClicks(self.qpart, "def main():") + QTest.keyClick(self.qpart, Qt.Key_Enter) + self.assertEqual(self.qpart.cursorPosition, (1, 4)) + + QTest.keyClicks(self.qpart, "return 7") + QTest.keyClick(self.qpart, Qt.Key_Enter) + self.assertEqual(self.qpart.cursorPosition, (2, 0)) + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/__init__.py b/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/__init__.py new file mode 100644 index 00000000000..9e07399d969 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/__init__.py @@ -0,0 +1,9 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/indenttest.py b/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/indenttest.py new file mode 100644 index 00000000000..9f0154748de --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/indenttest.py @@ -0,0 +1,68 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import sys +import os + +from AnyQt.QtCore import Qt +from AnyQt.QtTest import QTest + +from Orange.widgets.data.utils.pythoneditor.tests.base import SimpleWidget +from Orange.widgets.tests.base import WidgetTest + +# pylint: disable=protected-access + +topLevelPath = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, topLevelPath) +sys.path.insert(0, os.path.join(topLevelPath, 'tests')) + + +class IndentTest(WidgetTest): + """Base class for tests + """ + + def setUp(self): + self.widget = self.create_widget(SimpleWidget) + self.qpart = self.widget.qpart + if hasattr(self, 'INDENT_WIDTH'): + self.qpart.indentWidth = self.INDENT_WIDTH + + def setOrigin(self, text): + self.qpart.text = '\n'.join(text) + + def verifyExpected(self, text): + lines = self.qpart.text.split('\n') + self.assertEqual(text, lines) + + def setCursorPosition(self, line, col): + self.qpart.cursorPosition = line, col + + def enter(self): + QTest.keyClick(self.qpart, Qt.Key_Enter) + + def tab(self): + QTest.keyClick(self.qpart, Qt.Key_Tab) + + def type(self, text): + QTest.keyClicks(self.qpart, text) + + def writeCursorPosition(self): + line, col = self.qpart.cursorPosition + text = '(%d,%d)' % (line, col) + self.type(text) + + def writeln(self): + self.qpart.textCursor().insertText('\n') + + def alignLine(self, index): + self.qpart._indenter.autoIndentBlock(self.qpart.document().findBlockByNumber(index), '') + + def alignAll(self): + QTest.keyClick(self.qpart, Qt.Key_A, Qt.ControlModifier) + self.qpart.autoIndentLineAction.trigger() diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/test_python.py b/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/test_python.py new file mode 100755 index 00000000000..29c289f51d9 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/test_python.py @@ -0,0 +1,342 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import unittest + +import os.path +import sys + +from Orange.widgets.data.utils.pythoneditor.tests.test_indenter.indenttest import IndentTest + +sys.path.append(os.path.abspath(os.path.join(__file__, '..'))) + + +class Test(IndentTest): + LANGUAGE = 'Python' + INDENT_WIDTH = 2 + + def test_dedentReturn(self): + origin = [ + "def some_function():", + " return"] + expected = [ + "def some_function():", + " return", + "pass"] + + self.setOrigin(origin) + + self.setCursorPosition(1, 11) + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_dedentContinue(self): + origin = [ + "while True:", + " continue"] + expected = [ + "while True:", + " continue", + "pass"] + + self.setOrigin(origin) + + self.setCursorPosition(1, 11) + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_keepIndent2(self): + origin = [ + "class my_class():", + " def my_fun():", + ' print "Foo"', + " print 3"] + expected = [ + "class my_class():", + " def my_fun():", + ' print "Foo"', + " print 3", + " pass"] + + self.setOrigin(origin) + + self.setCursorPosition(3, 12) + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_keepIndent4(self): + origin = [ + "def some_function():"] + expected = [ + "def some_function():", + " pass", + "", + "pass"] + + self.setOrigin(origin) + + self.setCursorPosition(0, 22) + self.enter() + self.type("pass") + self.enter() + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_dedentRaise(self): + origin = [ + "try:", + " raise"] + expected = [ + "try:", + " raise", + "except:"] + + self.setOrigin(origin) + + self.setCursorPosition(1, 9) + self.enter() + self.type("except:") + self.verifyExpected(expected) + + def test_indentColon1(self): + origin = [ + "def some_function(param, param2):"] + expected = [ + "def some_function(param, param2):", + " pass"] + + self.setOrigin(origin) + + self.setCursorPosition(0, 34) + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_indentColon2(self): + origin = [ + "def some_function(1,", + " 2):" + ] + expected = [ + "def some_function(1,", + " 2):", + " pass" + ] + + self.setOrigin(origin) + + self.setCursorPosition(1, 21) + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_indentColon3(self): + """Do not indent colon if hanging indentation used + """ + origin = [ + " a = {1:" + ] + expected = [ + " a = {1:", + " x" + ] + + self.setOrigin(origin) + + self.setCursorPosition(0, 12) + self.enter() + self.type("x") + self.verifyExpected(expected) + + def test_dedentPass(self): + origin = [ + "def some_function():", + " pass"] + expected = [ + "def some_function():", + " pass", + "pass"] + + self.setOrigin(origin) + + self.setCursorPosition(1, 8) + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_dedentBreak(self): + origin = [ + "def some_function():", + " return"] + expected = [ + "def some_function():", + " return", + "pass"] + + self.setOrigin(origin) + + self.setCursorPosition(1, 11) + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_keepIndent3(self): + origin = [ + "while True:", + " returnFunc()", + " myVar = 3"] + expected = [ + "while True:", + " returnFunc()", + " myVar = 3", + " pass"] + + self.setOrigin(origin) + + self.setCursorPosition(2, 12) + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_keepIndent1(self): + origin = [ + "def some_function(param, param2):", + " a = 5", + " b = 7"] + expected = [ + "def some_function(param, param2):", + " a = 5", + " b = 7", + " pass"] + + self.setOrigin(origin) + + self.setCursorPosition(2, 8) + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_autoIndentAfterEmpty(self): + origin = [ + "while True:", + " returnFunc()", + "", + " myVar = 3"] + expected = [ + "while True:", + " returnFunc()", + "", + " x", + " myVar = 3"] + + self.setOrigin(origin) + + self.setCursorPosition(2, 0) + self.enter() + self.tab() + self.type("x") + self.verifyExpected(expected) + + def test_hangingIndentation(self): + origin = [ + " return func (something,", + ] + expected = [ + " return func (something,", + " x", + ] + + self.setOrigin(origin) + + self.setCursorPosition(0, 28) + self.enter() + self.type("x") + self.verifyExpected(expected) + + def test_hangingIndentation2(self): + origin = [ + " return func (", + " something,", + ] + expected = [ + " return func (", + " something,", + " x", + ] + + self.setOrigin(origin) + + self.setCursorPosition(1, 19) + self.enter() + self.type("x") + self.verifyExpected(expected) + + def test_hangingIndentation3(self): + origin = [ + " a = func (", + " something)", + ] + expected = [ + " a = func (", + " something)", + " x", + ] + + self.setOrigin(origin) + + self.setCursorPosition(1, 19) + self.enter() + self.type("x") + self.verifyExpected(expected) + + def test_hangingIndentation4(self): + origin = [ + " return func(a,", + " another_func(1,", + " 2),", + ] + expected = [ + " return func(a,", + " another_func(1,", + " 2),", + " x" + ] + + self.setOrigin(origin) + + self.setCursorPosition(2, 33) + self.enter() + self.type("x") + self.verifyExpected(expected) + + def test_hangingIndentation5(self): + origin = [ + " return func(another_func(1,", + " 2),", + ] + expected = [ + " return func(another_func(1,", + " 2),", + " x" + ] + + self.setOrigin(origin) + + self.setCursorPosition(2, 33) + self.enter() + self.type("x") + self.verifyExpected(expected) + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_rectangular_selection.py b/Orange/widgets/data/utils/pythoneditor/tests/test_rectangular_selection.py new file mode 100755 index 00000000000..310ca8afeaa --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_rectangular_selection.py @@ -0,0 +1,260 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import unittest + +# pylint: disable=line-too-long +# pylint: disable=protected-access +# pylint: disable=unused-variable + +from AnyQt.QtCore import Qt +from AnyQt.QtTest import QTest +from AnyQt.QtGui import QKeySequence + +from Orange.widgets.data.utils.pythoneditor.tests import base +from Orange.widgets.data.utils.pythoneditor.tests.base import SimpleWidget +from Orange.widgets.tests.base import WidgetTest + + +class _Test(WidgetTest): + """Base class for tests + """ + + def setUp(self): + self.widget = self.create_widget(SimpleWidget) + self.qpart = self.widget.qpart + + def tearDown(self): + self.qpart.hide() + self.qpart.terminate() + + def test_real_to_visible(self): + self.qpart.text = 'abcdfg' + self.assertEqual(0, self.qpart._rectangularSelection._realToVisibleColumn(self.qpart.text, 0)) + self.assertEqual(2, self.qpart._rectangularSelection._realToVisibleColumn(self.qpart.text, 2)) + self.assertEqual(6, self.qpart._rectangularSelection._realToVisibleColumn(self.qpart.text, 6)) + + self.qpart.text = '\tab\tcde\t' + self.assertEqual(0, self.qpart._rectangularSelection._realToVisibleColumn(self.qpart.text, 0)) + self.assertEqual(4, self.qpart._rectangularSelection._realToVisibleColumn(self.qpart.text, 1)) + self.assertEqual(5, self.qpart._rectangularSelection._realToVisibleColumn(self.qpart.text, 2)) + self.assertEqual(8, self.qpart._rectangularSelection._realToVisibleColumn(self.qpart.text, 4)) + self.assertEqual(12, self.qpart._rectangularSelection._realToVisibleColumn(self.qpart.text, 8)) + + def test_visible_to_real(self): + self.qpart.text = 'abcdfg' + self.assertEqual(0, self.qpart._rectangularSelection._visibleToRealColumn(self.qpart.text, 0)) + self.assertEqual(2, self.qpart._rectangularSelection._visibleToRealColumn(self.qpart.text, 2)) + self.assertEqual(6, self.qpart._rectangularSelection._visibleToRealColumn(self.qpart.text, 6)) + + self.qpart.text = '\tab\tcde\t' + self.assertEqual(0, self.qpart._rectangularSelection._visibleToRealColumn(self.qpart.text, 0)) + self.assertEqual(1, self.qpart._rectangularSelection._visibleToRealColumn(self.qpart.text, 4)) + self.assertEqual(2, self.qpart._rectangularSelection._visibleToRealColumn(self.qpart.text, 5)) + self.assertEqual(4, self.qpart._rectangularSelection._visibleToRealColumn(self.qpart.text, 8)) + self.assertEqual(8, self.qpart._rectangularSelection._visibleToRealColumn(self.qpart.text, 12)) + + self.assertEqual(None, self.qpart._rectangularSelection._visibleToRealColumn(self.qpart.text, 13)) + + def test_basic(self): + self.qpart.show() + for key in [Qt.Key_Delete, Qt.Key_Backspace]: + self.qpart.text = 'abcd\nef\nghkl\nmnop' + QTest.keyClick(self.qpart, Qt.Key_Right) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, key) + self.assertEqual(self.qpart.text, 'ad\ne\ngl\nmnop') + + def test_reset_by_move(self): + self.qpart.show() + self.qpart.text = 'abcd\nef\nghkl\nmnop' + QTest.keyClick(self.qpart, Qt.Key_Right) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Left) + QTest.keyClick(self.qpart, Qt.Key_Backspace) + self.assertEqual(self.qpart.text, 'abcd\nef\ngkl\nmnop') + + def test_reset_by_edit(self): + self.qpart.show() + self.qpart.text = 'abcd\nef\nghkl\nmnop' + QTest.keyClick(self.qpart, Qt.Key_Right) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClicks(self.qpart, 'x') + QTest.keyClick(self.qpart, Qt.Key_Backspace) + self.assertEqual(self.qpart.text, 'abcd\nef\nghkl\nmnop') + + def test_with_tabs(self): + self.qpart.show() + self.qpart.text = 'abcdefghhhhh\n\tklm\n\t\txyz' + self.qpart.cursorPosition = (0, 6) + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Delete) + + # 3 variants, Qt behavior differs on different systems + self.assertIn(self.qpart.text, ('abcdefhh\n\tkl\n\t\tz', + 'abcdefh\n\tkl\n\t\t', + 'abcdefhhh\n\tkl\n\t\tyz')) + + def test_delete(self): + self.qpart.show() + self.qpart.text = 'this is long\nshort\nthis is long' + self.qpart.cursorPosition = (0, 8) + for i in range(2): + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + for i in range(4): + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + + QTest.keyClick(self.qpart, Qt.Key_Delete) + self.assertEqual(self.qpart.text, 'this is \nshort\nthis is ') + + def test_copy_paste(self): + self.qpart.indentUseTabs = True + self.qpart.show() + self.qpart.text = 'xx 123 yy\n' + \ + 'xx 456 yy\n' + \ + 'xx 789 yy\n' + \ + '\n' + \ + 'asdfghijlmn\n' + \ + 'x\t\n' + \ + '\n' + \ + '\t\t\n' + \ + 'end\n' + self.qpart.cursorPosition = 0, 3 + for i in range(3): + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + for i in range(2): + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + + QTest.keyClick(self.qpart, Qt.Key_C, Qt.ControlModifier) + + self.qpart.cursorPosition = 4, 10 + QTest.keyClick(self.qpart, Qt.Key_V, Qt.ControlModifier) + + self.assertEqual(self.qpart.text, + 'xx 123 yy\nxx 456 yy\nxx 789 yy\n\nasdfghijlm123n\nx\t 456\n\t\t 789\n\t\t\nend\n') + + def test_copy_paste_utf8(self): + self.qpart.show() + self.qpart.text = 'фыва' + for i in range(3): + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_C, Qt.ControlModifier) + + QTest.keyClick(self.qpart, Qt.Key_Right) + QTest.keyClick(self.qpart, Qt.Key_Space) + QTest.keyClick(self.qpart, Qt.Key_V, Qt.ControlModifier) + + self.assertEqual(self.qpart.text, + 'фыва фыв') + + def test_paste_replace_selection(self): + self.qpart.show() + self.qpart.text = 'asdf' + + for i in range(4): + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_C, Qt.ControlModifier) + + QTest.keyClick(self.qpart, Qt.Key_End) + QTest.keyClick(self.qpart, Qt.Key_Left, Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_V, Qt.ControlModifier) + + self.assertEqual(self.qpart.text, + 'asdasdf') + + def test_paste_replace_rectangular_selection(self): + self.qpart.show() + self.qpart.text = 'asdf' + + for i in range(4): + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_C, Qt.ControlModifier) + + QTest.keyClick(self.qpart, Qt.Key_Left) + QTest.keyClick(self.qpart, Qt.Key_Left, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_V, Qt.ControlModifier) + + self.assertEqual(self.qpart.text, + 'asasdff') + + def test_paste_new_lines(self): + self.qpart.show() + self.qpart.text = 'a\nb\nc\nd' + + for i in range(4): + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_C, Qt.ControlModifier) + + self.qpart.text = 'x\ny' + self.qpart.cursorPosition = (1, 1) + + QTest.keyClick(self.qpart, Qt.Key_V, Qt.ControlModifier) + + self.assertEqual(self.qpart.text, + 'x\nya\n b\n c\n d') + + def test_cut(self): + self.qpart.show() + self.qpart.text = 'asdf' + + for i in range(4): + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_X, Qt.ControlModifier) + self.assertEqual(self.qpart.text, '') + + QTest.keyClick(self.qpart, Qt.Key_V, Qt.ControlModifier) + self.assertEqual(self.qpart.text, 'asdf') + + def test_cut_paste(self): + # Cursor must be moved to top-left after cut, and original text is restored after paste + + self.qpart.show() + self.qpart.text = 'abcd\nefgh\nklmn' + + QTest.keyClick(self.qpart, Qt.Key_Right) + for i in range(2): + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + for i in range(2): + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + + QTest.keyClick(self.qpart, Qt.Key_X, Qt.ControlModifier) + self.assertEqual(self.qpart.cursorPosition, (0, 1)) + + QTest.keyClick(self.qpart, Qt.Key_V, Qt.ControlModifier) + self.assertEqual(self.qpart.text, 'abcd\nefgh\nklmn') + + def test_warning(self): + self.qpart.show() + self.qpart.text = 'a\n' * 3000 + warning = [None] + def _saveWarning(text): + warning[0] = text + self.qpart.userWarning.connect(_saveWarning) + + base.keySequenceClicks(self.qpart, QKeySequence.SelectEndOfDocument, Qt.AltModifier) + + self.assertEqual(warning[0], 'Rectangular selection area is too big') + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_vim.py b/Orange/widgets/data/utils/pythoneditor/tests/test_vim.py new file mode 100755 index 00000000000..f2d290500ab --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_vim.py @@ -0,0 +1,1041 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import unittest + +from AnyQt.QtCore import Qt +from AnyQt.QtTest import QTest + +from Orange.widgets.data.utils.pythoneditor.tests.base import SimpleWidget +from Orange.widgets.data.utils.pythoneditor.vim import _globalClipboard +from Orange.widgets.tests.base import WidgetTest + +# pylint: disable=too-many-lines + + +class _Test(WidgetTest): + """Base class for tests + """ + + def setUp(self): + self.widget = self.create_widget(SimpleWidget) + self.qpart = self.widget.qpart + self.qpart.lines = ['The quick brown fox', + 'jumps over the', + 'lazy dog', + 'back'] + self.qpart.vimModeIndicationChanged.connect(self._onVimModeChanged) + + self.qpart.vimModeEnabled = True + self.vimMode = 'normal' + + def tearDown(self): + self.qpart.hide() + self.qpart.terminate() + + def _onVimModeChanged(self, _, mode): + self.vimMode = mode + + def click(self, keys): + if isinstance(keys, str): + for key in keys: + if key.isupper() or key in '$%^<>': + QTest.keyClick(self.qpart, key, Qt.ShiftModifier) + else: + QTest.keyClicks(self.qpart, key) + else: + QTest.keyClick(self.qpart, keys) + + +class Modes(_Test): + def test_01(self): + """Switch modes insert/normal + """ + self.assertEqual(self.vimMode, 'normal') + self.click("i123") + self.assertEqual(self.vimMode, 'insert') + self.click(Qt.Key_Escape) + self.assertEqual(self.vimMode, 'normal') + self.click("i4") + self.assertEqual(self.vimMode, 'insert') + self.assertEqual(self.qpart.lines[0], + '1234The quick brown fox') + + def test_02(self): + """Append with A + """ + self.qpart.cursorPosition = (2, 0) + self.click("A") + self.assertEqual(self.vimMode, 'insert') + self.click("XY") + + self.assertEqual(self.qpart.lines[2], + 'lazy dogXY') + + def test_03(self): + """Append with a + """ + self.qpart.cursorPosition = (2, 0) + self.click("a") + self.assertEqual(self.vimMode, 'insert') + self.click("XY") + + self.assertEqual(self.qpart.lines[2], + 'lXYazy dog') + + def test_04(self): + """Mode line shows composite command start + """ + self.assertEqual(self.vimMode, 'normal') + self.click('d') + self.assertEqual(self.vimMode, 'd') + self.click('w') + self.assertEqual(self.vimMode, 'normal') + + def test_05(self): + """ Replace mode + """ + self.assertEqual(self.vimMode, 'normal') + self.click('R') + self.assertEqual(self.vimMode, 'replace') + self.click('asdf') + self.assertEqual(self.qpart.lines[0], + 'asdfquick brown fox') + self.click(Qt.Key_Escape) + self.assertEqual(self.vimMode, 'normal') + + self.click('R') + self.assertEqual(self.vimMode, 'replace') + self.click(Qt.Key_Insert) + self.assertEqual(self.vimMode, 'insert') + + def test_05a(self): + """ Replace mode - at end of line + """ + self.click('$') + self.click('R') + self.click('asdf') + self.assertEqual(self.qpart.lines[0], + 'The quick brown foxasdf') + + def test_06(self): + """ Visual mode + """ + self.assertEqual(self.vimMode, 'normal') + + self.click('v') + self.assertEqual(self.vimMode, 'visual') + self.click(Qt.Key_Escape) + self.assertEqual(self.vimMode, 'normal') + + self.click('v') + self.assertEqual(self.vimMode, 'visual') + self.click('i') + self.assertEqual(self.vimMode, 'insert') + + def test_07(self): + """ Switch to visual on selection + """ + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.ShiftModifier) + self.assertEqual(self.vimMode, 'visual') + + def test_08(self): + """ From VISUAL to VISUAL LINES + """ + self.click('v') + self.click('kkk') + self.click('V') + self.assertEqual(self.qpart.selectedText, + 'The quick brown fox') + self.assertEqual(self.vimMode, 'visual lines') + + def test_09(self): + """ From VISUAL LINES to VISUAL + """ + self.click('V') + self.click('v') + self.assertEqual(self.qpart.selectedText, + 'The quick brown fox') + self.assertEqual(self.vimMode, 'visual') + + def test_10(self): + """ Insert mode with I + """ + self.qpart.lines[1] = ' indented line' + self.click('j8lI') + self.click('Z') + self.assertEqual(self.qpart.lines[1], + ' Zindented line') + + +class Move(_Test): + def test_01(self): + """Move hjkl + """ + self.click("ll") + self.assertEqual(self.qpart.cursorPosition, (0, 2)) + + self.click("jjj") + self.assertEqual(self.qpart.cursorPosition, (3, 2)) + + self.click("h") + self.assertEqual(self.qpart.cursorPosition, (3, 1)) + + self.click("k") + # (2, 1) on monospace, (2, 2) on non-monospace font + self.assertIn(self.qpart.cursorPosition, ((2, 1), (2, 2))) + + def test_02(self): + """w + """ + self.qpart.lines[0] = 'word, comma, word' + self.qpart.cursorPosition = (0, 0) + for column in (4, 6, 11, 13, 17, 0): + self.click('w') + self.assertEqual(self.qpart.cursorPosition[1], column) + + self.assertEqual(self.qpart.cursorPosition, (1, 0)) + + def test_03(self): + """e + """ + self.qpart.lines[0] = ' word, comma, word' + self.qpart.cursorPosition = (0, 0) + for column in (6, 7, 13, 14, 19, 5): + self.click('e') + self.assertEqual(self.qpart.cursorPosition[1], column) + + self.assertEqual(self.qpart.cursorPosition, (1, 5)) + + def test_04(self): + """$ + """ + self.click('$') + self.assertEqual(self.qpart.cursorPosition, (0, 19)) + self.click('$') + self.assertEqual(self.qpart.cursorPosition, (0, 19)) + + def test_05(self): + """0 + """ + self.qpart.cursorPosition = (0, 10) + self.click('0') + self.assertEqual(self.qpart.cursorPosition, (0, 0)) + + def test_06(self): + """G + """ + self.qpart.cursorPosition = (0, 10) + self.click('G') + self.assertEqual(self.qpart.cursorPosition, (3, 0)) + + def test_07(self): + """gg + """ + self.qpart.cursorPosition = (2, 10) + self.click('gg') + self.assertEqual(self.qpart.cursorPosition, (00, 0)) + + def test_08(self): + """ b word back + """ + self.qpart.cursorPosition = (0, 19) + self.click('b') + self.assertEqual(self.qpart.cursorPosition, (0, 16)) + + self.click('b') + self.assertEqual(self.qpart.cursorPosition, (0, 10)) + + def test_09(self): + """ % to jump to next braket + """ + self.qpart.lines[0] = '(asdf fdsa) xxx' + self.qpart.cursorPosition = (0, 0) + self.click('%') + self.assertEqual(self.qpart.cursorPosition, + (0, 10)) + + def test_10(self): + """ ^ to jump to the first non-space char + """ + self.qpart.lines[0] = ' indented line' + self.qpart.cursorPosition = (0, 14) + self.click('^') + self.assertEqual(self.qpart.cursorPosition, (0, 4)) + + def test_11(self): + """ f to search forward + """ + self.click('fv') + self.assertEqual(self.qpart.cursorPosition, + (1, 7)) + + def test_12(self): + """ F to search backward + """ + self.qpart.cursorPosition = (2, 0) + self.click('Fv') + self.assertEqual(self.qpart.cursorPosition, + (1, 7)) + + def test_13(self): + """ t to search forward + """ + self.click('tv') + self.assertEqual(self.qpart.cursorPosition, + (1, 6)) + + def test_14(self): + """ T to search backward + """ + self.qpart.cursorPosition = (2, 0) + self.click('Tv') + self.assertEqual(self.qpart.cursorPosition, + (1, 8)) + + def test_15(self): + """ f in a composite command + """ + self.click('dff') + self.assertEqual(self.qpart.lines[0], + 'ox') + + def test_16(self): + """ E + """ + self.qpart.lines[0] = 'asdfk.xx.z asdfk.xx.z asdfk.xx.z asdfk.xx.z' + self.qpart.cursorPosition = (0, 0) + for pos in (5, 6, 8, 9): + self.click('e') + self.assertEqual(self.qpart.cursorPosition[1], + pos) + self.qpart.cursorPosition = (0, 0) + for pos in (10, 22, 34, 45, 5): + self.click('E') + self.assertEqual(self.qpart.cursorPosition[1], + pos) + + def test_17(self): + """ W + """ + self.qpart.lines[0] = 'asdfk.xx.z asdfk.xx.z asdfk.xx.z asdfk.xx.z' + self.qpart.cursorPosition = (0, 0) + for pos in ((0, 12), (0, 24), (0, 35), (1, 0), (1, 6)): + self.click('W') + self.assertEqual(self.qpart.cursorPosition, + pos) + + def test_18(self): + """ B + """ + self.qpart.lines[0] = 'asdfk.xx.z asdfk.xx.z asdfk.xx.z asdfk.xx.z' + self.qpart.cursorPosition = (1, 8) + for pos in ((1, 6), (1, 0), (0, 35), (0, 24), (0, 12)): + self.click('B') + self.assertEqual(self.qpart.cursorPosition, + pos) + + def test_19(self): + """ Enter, Return + """ + self.qpart.lines[1] = ' indented line' + self.qpart.lines[2] = ' more indented line' + self.click(Qt.Key_Enter) + self.assertEqual(self.qpart.cursorPosition, (1, 3)) + self.click(Qt.Key_Return) + self.assertEqual(self.qpart.cursorPosition, (2, 5)) + + +class Del(_Test): + def test_01a(self): + """Delete with x + """ + self.qpart.cursorPosition = (0, 4) + self.click("xxxxx") + + self.assertEqual(self.qpart.lines[0], + 'The brown fox') + self.assertEqual(_globalClipboard.value, 'k') + + def test_01b(self): + """Delete with x. Use count + """ + self.qpart.cursorPosition = (0, 4) + self.click("5x") + + self.assertEqual(self.qpart.lines[0], + 'The brown fox') + self.assertEqual(_globalClipboard.value, 'quick') + + def test_02(self): + """Composite delete with d. Left and right + """ + self.qpart.cursorPosition = (1, 1) + self.click("dl") + self.assertEqual(self.qpart.lines[1], + 'jmps over the') + + self.click("dh") + self.assertEqual(self.qpart.lines[1], + 'mps over the') + + def test_03(self): + """Composite delete with d. Down + """ + self.qpart.cursorPosition = (0, 2) + self.click('dj') + self.assertEqual(self.qpart.lines[:], + ['lazy dog', + 'back']) + self.assertEqual(self.qpart.cursorPosition[1], 0) + + # nothing deleted, if having only one line + self.qpart.cursorPosition = (1, 1) + self.click('dj') + self.assertEqual(self.qpart.lines[:], + ['lazy dog', + 'back']) + + + self.click('k') + self.click('dj') + self.assertEqual(self.qpart.lines[:], + ['']) + self.assertEqual(_globalClipboard.value, + ['lazy dog', + 'back']) + + def test_04(self): + """Composite delete with d. Up + """ + self.qpart.cursorPosition = (0, 2) + self.click('dk') + self.assertEqual(len(self.qpart.lines), 4) + + self.qpart.cursorPosition = (2, 1) + self.click('dk') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'back']) + self.assertEqual(_globalClipboard.value, + ['jumps over the', + 'lazy dog']) + + self.assertEqual(self.qpart.cursorPosition[1], 0) + + def test_05(self): + """Delete Count times + """ + self.click('3dw') + self.assertEqual(self.qpart.lines[0], 'fox') + self.assertEqual(_globalClipboard.value, + 'The quick brown ') + + def test_06(self): + """Delete line + dd + """ + self.qpart.cursorPosition = (1, 0) + self.click('dd') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'lazy dog', + 'back']) + + def test_07(self): + """Delete until end of file + G + """ + self.qpart.cursorPosition = (2, 0) + self.click('dG') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'jumps over the']) + + def test_08(self): + """Delete until start of file + gg + """ + self.qpart.cursorPosition = (1, 0) + self.click('dgg') + self.assertEqual(self.qpart.lines[:], + ['lazy dog', + 'back']) + + def test_09(self): + """Delete with X + """ + self.click("llX") + + self.assertEqual(self.qpart.lines[0], + 'Te quick brown fox') + + def test_10(self): + """Delete with D + """ + self.click("jll") + self.click("2D") + + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'ju', + 'back']) + + +class Edit(_Test): + def test_01(self): + """Undo + """ + oldText = self.qpart.text + self.click('ddu') + modifiedText = self.qpart.text # pylint: disable=unused-variable + self.assertEqual(self.qpart.text, oldText) + # NOTE this part of test doesn't work. Don't know why. + # self.click('U') + # self.assertEqual(self.qpart.text, modifiedText) + + def test_02(self): + """Change with C + """ + self.click("lllCpig") + + self.assertEqual(self.qpart.lines[0], + 'Thepig') + + def test_03(self): + """ Substitute with s + """ + self.click('j4sz') + self.assertEqual(self.qpart.lines[1], + 'zs over the') + + def test_04(self): + """Replace char with r + """ + self.qpart.cursorPosition = (0, 4) + self.click('rZ') + self.assertEqual(self.qpart.lines[0], + 'The Zuick brown fox') + + self.click('rW') + self.assertEqual(self.qpart.lines[0], + 'The Wuick brown fox') + + def test_05(self): + """Change 2 words with c + """ + self.click('c2e') + self.click('asdf') + self.assertEqual(self.qpart.lines[0], + 'asdf brown fox') + + def test_06(self): + """Open new line with o + """ + self.qpart.lines = [' indented line', + ' next indented line'] + self.click('o') + self.click('asdf') + self.assertEqual(self.qpart.lines[:], + [' indented line', + ' asdf', + ' next indented line']) + + def test_07(self): + """Open new line with O + + Check indentation + """ + self.qpart.lines = [' indented line', + ' next indented line'] + self.click('j') + self.click('O') + self.click('asdf') + self.assertEqual(self.qpart.lines[:], + [' indented line', + ' asdf', + ' next indented line']) + + def test_08(self): + """ Substitute with S + """ + self.qpart.lines = [' indented line', + ' next indented line'] + self.click('ljS') + self.click('xyz') + self.assertEqual(self.qpart.lines[:], + [' indented line', + ' xyz']) + + def test_09(self): + """ % to jump to next braket + """ + self.qpart.lines[0] = '(asdf fdsa) xxx' + self.qpart.cursorPosition = (0, 0) + self.click('d%') + self.assertEqual(self.qpart.lines[0], + ' xxx') + + def test_10(self): + """ J join lines + """ + self.click('2J') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox jumps over the lazy dog', + 'back']) + + +class Indent(_Test): + def test_01(self): + """ Increase indent with >j, decrease with 2j') + self.assertEqual(self.qpart.lines[:], + [' The quick brown fox', + ' jumps over the', + ' lazy dog', + 'back']) + + self.click('>, decrease with << + """ + self.click('>>') + self.click('>>') + self.assertEqual(self.qpart.lines[0], + ' The quick brown fox') + + self.click('<<') + self.assertEqual(self.qpart.lines[0], + ' The quick brown fox') + + def test_03(self): + """ Autoindent with =j + """ + self.click('i ') + self.click(Qt.Key_Escape) + self.click('j') + self.click('=j') + self.assertEqual(self.qpart.lines[:], + [' The quick brown fox', + ' jumps over the', + ' lazy dog', + 'back']) + + def test_04(self): + """ Autoindent with == + """ + self.click('i ') + self.click(Qt.Key_Escape) + self.click('j') + self.click('==') + self.assertEqual(self.qpart.lines[:], + [' The quick brown fox', + ' jumps over the', + 'lazy dog', + 'back']) + + def test_11(self): + """ Increase indent with >, decrease with < in visual mode + """ + self.click('v2>') + self.assertEqual(self.qpart.lines[:2], + [' The quick brown fox', + 'jumps over the']) + + self.click('v<') + self.assertEqual(self.qpart.lines[:2], + [' The quick brown fox', + 'jumps over the']) + + def test_12(self): + """ Autoindent with = in visual mode + """ + self.click('i ') + self.click(Qt.Key_Escape) + self.click('j') + self.click('Vj=') + self.assertEqual(self.qpart.lines[:], + [' The quick brown fox', + ' jumps over the', + ' lazy dog', + 'back']) + + +class CopyPaste(_Test): + def test_02(self): + """Paste text with p + """ + self.qpart.cursorPosition = (0, 4) + self.click("5x") + self.assertEqual(self.qpart.lines[0], + 'The brown fox') + + self.click("p") + self.assertEqual(self.qpart.lines[0], + 'The quickbrown fox') + + def test_03(self): + """Paste lines with p + """ + self.qpart.cursorPosition = (1, 2) + self.click("2dd") + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'back']) + + self.click("kkk") + self.click("p") + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'jumps over the', + 'lazy dog', + 'back']) + + def test_04(self): + """Paste lines with P + """ + self.qpart.cursorPosition = (1, 2) + self.click("2dd") + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'back']) + + self.click("P") + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'jumps over the', + 'lazy dog', + 'back']) + + def test_05(self): + """ Yank line with yy + """ + self.click('y2y') + self.click('jll') + self.click('p') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'jumps over the', + 'The quick brown fox', + 'jumps over the', + 'lazy dog', + 'back']) + + def test_06(self): + """ Yank until the end of line + """ + self.click('2wYo') + self.click(Qt.Key_Escape) + self.click('P') + self.assertEqual(self.qpart.lines[1], + 'brown fox') + + def test_08(self): + """ Composite yank with y, paste with P + """ + self.click('y2w') + self.click('P') + self.assertEqual(self.qpart.lines[0], + 'The quick The quick brown fox') + + + + +class Visual(_Test): + def test_01(self): + """ x + """ + self.click('v') + self.assertEqual(self.vimMode, 'visual') + self.click('2w') + self.assertEqual(self.qpart.selectedText, 'The quick ') + self.click('x') + self.assertEqual(self.qpart.lines[0], + 'brown fox') + self.assertEqual(self.vimMode, 'normal') + + def test_02(self): + """Append with a + """ + self.click("vllA") + self.click("asdf ") + self.assertEqual(self.qpart.lines[0], + 'The asdf quick brown fox') + + def test_03(self): + """Replace with r + """ + self.qpart.cursorPosition = (0, 16) + self.click("v8l") + self.click("rz") + self.assertEqual(self.qpart.lines[0:2], + ['The quick brown zzz', + 'zzzzz over the']) + + def test_04(self): + """Replace selected lines with R + """ + self.click("vjl") + self.click("R") + self.click("Z") + self.assertEqual(self.qpart.lines[:], + ['Z', + 'lazy dog', + 'back']) + + def test_05(self): + """Reset selection with u + """ + self.qpart.cursorPosition = (1, 3) + self.click('vjl') + self.click('u') + self.assertEqual(self.qpart.selectedPosition, ((1, 3), (1, 3))) + + def test_06(self): + """Yank with y and paste with p + """ + self.qpart.cursorPosition = (0, 4) + self.click("ve") + #print self.qpart.selectedText + self.click("y") + self.click(Qt.Key_Escape) + self.qpart.cursorPosition = (0, 16) + self.click("ve") + self.click("p") + self.assertEqual(self.qpart.lines[0], + 'The quick brown quick') + + def test_07(self): + """ Replace word when pasting + """ + self.click("vey") # copy word + self.click('ww') # move + self.click('vep') # replace word + self.assertEqual(self.qpart.lines[0], + 'The quick The fox') + + def test_08(self): + """Change with c + """ + self.click("w") + self.click("vec") + self.click("slow") + self.assertEqual(self.qpart.lines[0], + 'The slow brown fox') + + def test_09(self): + """ Delete lines with X and D + """ + self.click('jvlX') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'lazy dog', + 'back']) + + self.click('u') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'jumps over the', + 'lazy dog', + 'back']) + + self.click('vjD') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'back']) + + def test_10(self): + """ Check if f works + """ + self.click('vfo') + self.assertEqual(self.qpart.selectedText, + 'The quick bro') + + def test_11(self): + """ J join lines + """ + self.click('jvjJ') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'jumps over the lazy dog', + 'back']) + + +class VisualLines(_Test): + def test_01(self): + """ x Delete + """ + self.click('V') + self.assertEqual(self.vimMode, 'visual lines') + self.click('x') + self.click('p') + self.assertEqual(self.qpart.lines[:], + ['jumps over the', + 'The quick brown fox', + 'lazy dog', + 'back']) + self.assertEqual(self.vimMode, 'normal') + + def test_02(self): + """ Replace text when pasting + """ + self.click('Vy') + self.click('j') + self.click('Vp') + self.assertEqual(self.qpart.lines[0:3], + ['The quick brown fox', + 'The quick brown fox', + 'lazy dog',]) + + def test_06(self): + """Yank with y and paste with p + """ + self.qpart.cursorPosition = (0, 4) + self.click("V") + self.click("y") + self.click(Qt.Key_Escape) + self.qpart.cursorPosition = (0, 16) + self.click("p") + self.assertEqual(self.qpart.lines[0:3], + ['The quick brown fox', + 'The quick brown fox', + 'jumps over the']) + + def test_07(self): + """Change with c + """ + self.click("Vc") + self.click("slow") + self.assertEqual(self.qpart.lines[0], + 'slow') + + +class Repeat(_Test): + def test_01(self): + """ Repeat o + """ + self.click('o') + self.click(Qt.Key_Escape) + self.click('j2.') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + '', + 'jumps over the', + '', + '', + 'lazy dog', + 'back']) + + def test_02(self): + """ Repeat o. Use count from previous command + """ + self.click('2o') + self.click(Qt.Key_Escape) + self.click('j.') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + '', + '', + 'jumps over the', + '', + '', + 'lazy dog', + 'back']) + + def test_03(self): + """ Repeat O + """ + self.click('O') + self.click(Qt.Key_Escape) + self.click('2j2.') + self.assertEqual(self.qpart.lines[:], + ['', + 'The quick brown fox', + '', + '', + 'jumps over the', + 'lazy dog', + 'back']) + + def test_04(self): + """ Repeat p + """ + self.click('ylp.') + self.assertEqual(self.qpart.lines[0], + 'TTThe quick brown fox') + + def test_05(self): + """ Repeat p + """ + self.click('x...') + self.assertEqual(self.qpart.lines[0], + 'quick brown fox') + + def test_06(self): + """ Repeat D + """ + self.click('Dj.') + self.assertEqual(self.qpart.lines[:], + ['', + '', + 'lazy dog', + 'back']) + + def test_07(self): + """ Repeat dw + """ + self.click('dw') + self.click('j0.') + self.assertEqual(self.qpart.lines[:], + ['quick brown fox', + 'over the', + 'lazy dog', + 'back']) + + def test_08(self): + """ Repeat Visual x + """ + self.qpart.lines.append('one more') + self.click('Vjx') + self.click('.') + self.assertEqual(self.qpart.lines[:], + ['one more']) + + def test_09(self): + """ Repeat visual X + """ + self.qpart.lines.append('one more') + self.click('vjX') + self.click('.') + self.assertEqual(self.qpart.lines[:], + ['one more']) + + def test_10(self): + """ Repeat Visual > + """ + self.qpart.lines.append('one more') + self.click('Vj>') + self.click('3j') + self.click('.') + self.assertEqual(self.qpart.lines[:], + [' The quick brown fox', + ' jumps over the', + 'lazy dog', + ' back', + ' one more']) + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/data/utils/pythoneditor/vim.py b/Orange/widgets/data/utils/pythoneditor/vim.py new file mode 100644 index 00000000000..65d45579c44 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/vim.py @@ -0,0 +1,1287 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" +import sys + +from PyQt5.QtCore import Qt, pyqtSignal, QObject +from PyQt5.QtWidgets import QTextEdit +from PyQt5.QtGui import QColor, QTextCursor + +# pylint: disable=protected-access +# pylint: disable=unused-argument +# pylint: disable=too-many-lines +# pylint: disable=too-many-branches + +# This magic code sets variables like _a and _A in the global scope +# pylint: disable=undefined-variable +thismodule = sys.modules[__name__] +for charCode in range(ord('a'), ord('z') + 1): + shortName = chr(charCode) + longName = 'Key_' + shortName.upper() + qtCode = getattr(Qt, longName) + setattr(thismodule, '_' + shortName, qtCode) + setattr(thismodule, '_' + shortName.upper(), Qt.ShiftModifier + qtCode) + +_0 = Qt.Key_0 +_Dollar = Qt.ShiftModifier + Qt.Key_Dollar +_Percent = Qt.ShiftModifier + Qt.Key_Percent +_Caret = Qt.ShiftModifier + Qt.Key_AsciiCircum +_Esc = Qt.Key_Escape +_Insert = Qt.Key_Insert +_Down = Qt.Key_Down +_Up = Qt.Key_Up +_Left = Qt.Key_Left +_Right = Qt.Key_Right +_Space = Qt.Key_Space +_BackSpace = Qt.Key_Backspace +_Equal = Qt.Key_Equal +_Less = Qt.ShiftModifier + Qt.Key_Less +_Greater = Qt.ShiftModifier + Qt.Key_Greater +_Home = Qt.Key_Home +_End = Qt.Key_End +_PageDown = Qt.Key_PageDown +_PageUp = Qt.Key_PageUp +_Period = Qt.Key_Period +_Enter = Qt.Key_Enter +_Return = Qt.Key_Return + + +def code(ev): + modifiers = ev.modifiers() + modifiers &= ~Qt.KeypadModifier # ignore keypad modifier to handle both main and numpad numbers + return int(modifiers) + ev.key() + + +def isChar(ev): + """ Check if an event may be a typed character + """ + text = ev.text() + if len(text) != 1: + return False + + if ev.modifiers() not in (Qt.ShiftModifier, Qt.KeypadModifier, Qt.NoModifier): + return False + + asciiCode = ord(text) + if asciiCode <= 31 or asciiCode == 0x7f: # control characters + return False + + if text == ' ' and ev.modifiers() == Qt.ShiftModifier: + return False # Shift+Space is a shortcut, not a text + + return True + + +NORMAL = 'normal' +INSERT = 'insert' +REPLACE_CHAR = 'replace character' + +MODE_COLORS = {NORMAL: QColor('#33cc33'), + INSERT: QColor('#ff9900'), + REPLACE_CHAR: QColor('#ff3300')} + + +class _GlobalClipboard: + def __init__(self): + self.value = '' + + +_globalClipboard = _GlobalClipboard() + + +class Vim(QObject): + """Vim mode implementation. + Listens events and does actions + """ + modeIndicationChanged = pyqtSignal(QColor, str) + + def __init__(self, qpart): + QObject.__init__(self) + self._qpart = qpart + self._mode = Normal(self, qpart) + + self._qpart.selectionChanged.connect(self._onSelectionChanged) + self._qpart.document().modificationChanged.connect(self._onModificationChanged) + + self._processingKeyPress = False + + self.updateIndication() + + self.lastEditCmdFunc = None + + def terminate(self): + self._qpart.selectionChanged.disconnect(self._onSelectionChanged) + try: + self._qpart.document().modificationChanged.disconnect(self._onModificationChanged) + except TypeError: + pass + + def indication(self): + return self._mode.color, self._mode.text() + + def updateIndication(self): + self.modeIndicationChanged.emit(*self.indication()) + + def keyPressEvent(self, ev): + """Check the event. Return True if processed and False otherwise + """ + if ev.key() in (Qt.Key_Shift, Qt.Key_Control, + Qt.Key_Meta, Qt.Key_Alt, + Qt.Key_AltGr, Qt.Key_CapsLock, + Qt.Key_NumLock, Qt.Key_ScrollLock): + return False # ignore modifier pressing. Will process key pressing later + + self._processingKeyPress = True + try: + ret = self._mode.keyPressEvent(ev) + finally: + self._processingKeyPress = False + return ret + + def inInsertMode(self): + return isinstance(self._mode, Insert) + + def mode(self): + return self._mode + + def setMode(self, mode): + self._mode = mode + + self._qpart._updateVimExtraSelections() + + self.updateIndication() + + def extraSelections(self): + """ In normal mode - QTextEdit.ExtraSelection which highlightes the cursor + """ + if not isinstance(self._mode, Normal): + return [] + + selection = QTextEdit.ExtraSelection() + selection.format.setBackground(QColor('#ffcc22')) + selection.format.setForeground(QColor('#000000')) + selection.cursor = self._qpart.textCursor() + selection.cursor.movePosition(QTextCursor.NextCharacter, QTextCursor.KeepAnchor) + + return [selection] + + def _onSelectionChanged(self): + if not self._processingKeyPress: + if self._qpart.selectedText: + if not isinstance(self._mode, (Visual, VisualLines)): + self.setMode(Visual(self, self._qpart)) + else: + self.setMode(Normal(self, self._qpart)) + + def _onModificationChanged(self, modified): + if not modified and isinstance(self._mode, Insert): + self.setMode(Normal(self, self._qpart)) + + +class Mode: + # pylint: disable=no-self-use + color = None + + def __init__(self, vim, qpart): + self._vim = vim + self._qpart = qpart + + def text(self): + return None + + def keyPressEvent(self, ev): + pass + + def switchMode(self, modeClass, *args): + mode = modeClass(self._vim, self._qpart, *args) + self._vim.setMode(mode) + + def switchModeAndProcess(self, text, modeClass, *args): + mode = modeClass(self._vim, self._qpart, *args) + self._vim.setMode(mode) + return mode.keyPressEvent(text) + + +class Insert(Mode): + color = QColor('#ff9900') + + def text(self): + return 'insert' + + def keyPressEvent(self, ev): + if ev.key() == Qt.Key_Escape: + self.switchMode(Normal) + return True + + return False + + +class ReplaceChar(Mode): + color = QColor('#ee7777') + + def text(self): + return 'replace char' + + def keyPressEvent(self, ev): + if isChar(ev): # a char + self._qpart.setOverwriteMode(False) + line, col = self._qpart.cursorPosition + if col > 0: + # return the cursor back after replacement + self._qpart.cursorPosition = (line, col - 1) + self.switchMode(Normal) + return True + else: + self._qpart.setOverwriteMode(False) + self.switchMode(Normal) + return False + + +class Replace(Mode): + color = QColor('#ee7777') + + def text(self): + return 'replace' + + def keyPressEvent(self, ev): + if ev.key() == _Insert: + self._qpart.setOverwriteMode(False) + self.switchMode(Insert) + return True + elif ev.key() == _Esc: + self._qpart.setOverwriteMode(False) + self.switchMode(Normal) + return True + else: + return False + + +class BaseCommandMode(Mode): + """ Base class for Normal and Visual modes + """ + + def __init__(self, *args): + Mode.__init__(self, *args) + self._reset() + + def keyPressEvent(self, ev): + self._typedText += ev.text() + try: + self._processCharCoroutine.send(ev) + except StopIteration as ex: + retVal = ex.value + self._reset() + else: + retVal = True + + self._vim.updateIndication() + + return retVal + + def text(self): + return self._typedText or self.name + + def _reset(self): + self._processCharCoroutine = self._processChar() + next(self._processCharCoroutine) # run until the first yield + self._typedText = '' + + _MOTIONS = (_0, _Home, + _Dollar, _End, + _Percent, _Caret, + _b, _B, + _e, _E, + _G, + _j, _Down, + _l, _Right, _Space, + _k, _Up, + _h, _Left, _BackSpace, + _w, _W, + 'gg', + _f, _F, _t, _T, + _PageDown, _PageUp, + _Enter, _Return, + ) + + @staticmethod + def moveToFirstNonSpace(cursor, moveMode): + text = cursor.block().text() + spaceLen = len(text) - len(text.lstrip()) + cursor.setPosition(cursor.block().position() + spaceLen, moveMode) + + def _moveCursor(self, motion, count, searchChar=None, select=False): + """ Move cursor. + Used by Normal and Visual mode + """ + cursor = self._qpart.textCursor() + + effectiveCount = count or 1 + + moveMode = QTextCursor.KeepAnchor if select else QTextCursor.MoveAnchor + + moveOperation = {_b: QTextCursor.WordLeft, + _j: QTextCursor.Down, + _Down: QTextCursor.Down, + _k: QTextCursor.Up, + _Up: QTextCursor.Up, + _h: QTextCursor.Left, + _Left: QTextCursor.Left, + _BackSpace: QTextCursor.Left, + _l: QTextCursor.Right, + _Right: QTextCursor.Right, + _Space: QTextCursor.Right, + _w: QTextCursor.WordRight, + _Dollar: QTextCursor.EndOfBlock, + _End: QTextCursor.EndOfBlock, + _0: QTextCursor.StartOfBlock, + _Home: QTextCursor.StartOfBlock, + 'gg': QTextCursor.Start, + _G: QTextCursor.End + } + + if motion == _G: + if count == 0: # default - go to the end + cursor.movePosition(QTextCursor.End, moveMode) + else: # if count is set - move to line + block = self._qpart.document().findBlockByNumber(count - 1) + if not block.isValid(): + return + cursor.setPosition(block.position(), moveMode) + self.moveToFirstNonSpace(cursor, moveMode) + elif motion in moveOperation: + for _ in range(effectiveCount): + cursor.movePosition(moveOperation[motion], moveMode) + elif motion in (_e, _E): + for _ in range(effectiveCount): + # skip spaces + text = cursor.block().text() + pos = cursor.positionInBlock() + for char in text[pos:]: + if char.isspace(): + cursor.movePosition(QTextCursor.NextCharacter, moveMode) + else: + break + + if cursor.positionInBlock() == len(text): # at the end of line + # move to the next line + cursor.movePosition(QTextCursor.NextCharacter, moveMode) + + # now move to the end of word + if motion == _e: + cursor.movePosition(QTextCursor.EndOfWord, moveMode) + else: + text = cursor.block().text() + pos = cursor.positionInBlock() + for char in text[pos:]: + if not char.isspace(): + cursor.movePosition(QTextCursor.NextCharacter, moveMode) + else: + break + elif motion == _B: + cursor.movePosition(QTextCursor.WordLeft, moveMode) + while cursor.positionInBlock() != 0 and \ + (not cursor.block().text()[cursor.positionInBlock() - 1].isspace()): + cursor.movePosition(QTextCursor.WordLeft, moveMode) + elif motion == _W: + cursor.movePosition(QTextCursor.WordRight, moveMode) + while cursor.positionInBlock() != 0 and \ + (not cursor.block().text()[cursor.positionInBlock() - 1].isspace()): + cursor.movePosition(QTextCursor.WordRight, moveMode) + elif motion == _Percent: + # Percent move is done only once + if self._qpart._bracketHighlighter.currentMatchedBrackets is not None: + ((startBlock, startCol), (endBlock, endCol)) = \ + self._qpart._bracketHighlighter.currentMatchedBrackets + startPos = startBlock.position() + startCol + endPos = endBlock.position() + endCol + if select and \ + (endPos > startPos): + endPos += 1 # to select the bracket, not only chars before it + cursor.setPosition(endPos, moveMode) + elif motion == _Caret: + # Caret move is done only once + self.moveToFirstNonSpace(cursor, moveMode) + elif motion in (_f, _F, _t, _T): + if motion in (_f, _t): + iterator = self._iterateDocumentCharsForward(cursor.block(), cursor.columnNumber()) + stepForward = QTextCursor.Right + stepBack = QTextCursor.Left + else: + iterator = self._iterateDocumentCharsBackward(cursor.block(), cursor.columnNumber()) + stepForward = QTextCursor.Left + stepBack = QTextCursor.Right + + for block, columnIndex, char in iterator: + if char == searchChar: + cursor.setPosition(block.position() + columnIndex, moveMode) + if motion in (_t, _T): + cursor.movePosition(stepBack, moveMode) + if select: + cursor.movePosition(stepForward, moveMode) + break + elif motion in (_PageDown, _PageUp): + cursorHeight = self._qpart.cursorRect().height() + qpartHeight = self._qpart.height() + visibleLineCount = qpartHeight / cursorHeight + direction = QTextCursor.Down if motion == _PageDown else QTextCursor.Up + for _ in range(int(visibleLineCount)): + cursor.movePosition(direction, moveMode) + elif motion in (_Enter, _Return): + if cursor.block().next().isValid(): # not the last line + for _ in range(effectiveCount): + cursor.movePosition(QTextCursor.NextBlock, moveMode) + self.moveToFirstNonSpace(cursor, moveMode) + else: + assert 0, 'Not expected motion ' + str(motion) + + self._qpart.setTextCursor(cursor) + + @staticmethod + def _iterateDocumentCharsForward(block, startColumnIndex): + """Traverse document forward. Yield (block, columnIndex, char) + Raise _TimeoutException if time is over + """ + # Chars in the start line + for columnIndex, char in list(enumerate(block.text()))[startColumnIndex:]: + yield block, columnIndex, char + block = block.next() + + # Next lines + while block.isValid(): + for columnIndex, char in enumerate(block.text()): + yield block, columnIndex, char + + block = block.next() + + @staticmethod + def _iterateDocumentCharsBackward(block, startColumnIndex): + """Traverse document forward. Yield (block, columnIndex, char) + Raise _TimeoutException if time is over + """ + # Chars in the start line + for columnIndex, char in reversed(list(enumerate(block.text()[:startColumnIndex]))): + yield block, columnIndex, char + block = block.previous() + + # Next lines + while block.isValid(): + for columnIndex, char in reversed(list(enumerate(block.text()))): + yield block, columnIndex, char + + block = block.previous() + + def _resetSelection(self, moveToTop=False): + """ Reset selection. + If moveToTop is True - move cursor to the top position + """ + ancor, pos = self._qpart.selectedPosition + dst = min(ancor, pos) if moveToTop else pos + self._qpart.cursorPosition = dst + + def _expandSelection(self): + cursor = self._qpart.textCursor() + anchor = cursor.anchor() + pos = cursor.position() + + if pos >= anchor: + anchorSide = QTextCursor.StartOfBlock + cursorSide = QTextCursor.EndOfBlock + else: + anchorSide = QTextCursor.EndOfBlock + cursorSide = QTextCursor.StartOfBlock + + cursor.setPosition(anchor) + cursor.movePosition(anchorSide) + cursor.setPosition(pos, QTextCursor.KeepAnchor) + cursor.movePosition(cursorSide, QTextCursor.KeepAnchor) + + self._qpart.setTextCursor(cursor) + + +class BaseVisual(BaseCommandMode): + color = QColor('#6699ff') + _selectLines = NotImplementedError() + + def _processChar(self): + ev = yield None + + # Get count + typedCount = 0 + + if ev.key() != _0: + char = ev.text() + while char.isdigit(): + digit = int(char) + typedCount = (typedCount * 10) + digit + ev = yield + char = ev.text() + + count = typedCount if typedCount else 1 + + # Now get the action + action = code(ev) + if action in self._SIMPLE_COMMANDS: + cmdFunc = self._SIMPLE_COMMANDS[action] + for _ in range(count): + cmdFunc(self, action) + if action not in (_v, _V): # if not switched to another visual mode + self._resetSelection(moveToTop=True) + if self._vim.mode() is self: # if the command didn't switch the mode + self.switchMode(Normal) + + return True + elif action == _Esc: + self._resetSelection() + self.switchMode(Normal) + return True + elif action == _g: + ev = yield + if code(ev) == _g: + self._moveCursor('gg', 1, select=True) + if self._selectLines: + self._expandSelection() + return True + elif action in (_f, _F, _t, _T): + ev = yield + if not isChar(ev): + return True + + searchChar = ev.text() + self._moveCursor(action, typedCount, searchChar=searchChar, select=True) + return True + elif action == _z: + ev = yield + if code(ev) == _z: + self._qpart.centerCursor() + return True + elif action in self._MOTIONS: + if self._selectLines and action in (_k, _Up, _j, _Down): + # There is a bug in visual mode: + # If a line is wrapped, cursor moves up, but stays on same line. + # Then selection is expanded and cursor returns to previous position. + # So user can't move the cursor up. So, in Visual mode we move cursor up until it + # moved to previous line. The same bug when moving down + cursorLine = self._qpart.cursorPosition[0] + if (action in (_k, _Up) and cursorLine > 0) or \ + (action in (_j, _Down) and (cursorLine + 1) < len(self._qpart.lines)): + while self._qpart.cursorPosition[0] == cursorLine: + self._moveCursor(action, typedCount, select=True) + else: + self._moveCursor(action, typedCount, select=True) + + if self._selectLines: + self._expandSelection() + return True + elif action == _r: + ev = yield + newChar = ev.text() + if newChar: + newChars = [newChar if char != '\n' else '\n' \ + for char in self._qpart.selectedText + ] + newText = ''.join(newChars) + self._qpart.selectedText = newText + self.switchMode(Normal) + return True + elif isChar(ev): + return True # ignore unknown character + else: + return False # but do not ignore not-a-character keys + + assert 0 # must StopIteration on if + + def _selectedLinesRange(self): + """ Selected lines range for line manipulation methods + """ + (startLine, _), (endLine, _) = self._qpart.selectedPosition + start = min(startLine, endLine) + end = max(startLine, endLine) + return start, end + + def _selectRangeForRepeat(self, repeatLineCount): + start = self._qpart.cursorPosition[0] + self._qpart.selectedPosition = ((start, 0), + (start + repeatLineCount - 1, 0)) + cursor = self._qpart.textCursor() + # expand until the end of line + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + self._qpart.setTextCursor(cursor) + + def _saveLastEditLinesCmd(self, cmd, lineCount): + self._vim.lastEditCmdFunc = lambda: self._SIMPLE_COMMANDS[cmd](self, cmd, lineCount) + + # + # Simple commands + # + + def cmdDelete(self, cmd, repeatLineCount=None): + if repeatLineCount is not None: + self._selectRangeForRepeat(repeatLineCount) + + cursor = self._qpart.textCursor() + if cursor.selectedText(): + if self._selectLines: + start, end = self._selectedLinesRange() + self._saveLastEditLinesCmd(cmd, end - start + 1) + _globalClipboard.value = self._qpart.lines[start:end + 1] + del self._qpart.lines[start:end + 1] + else: + _globalClipboard.value = cursor.selectedText() + cursor.removeSelectedText() + + def cmdDeleteLines(self, cmd, repeatLineCount=None): + if repeatLineCount is not None: + self._selectRangeForRepeat(repeatLineCount) + + start, end = self._selectedLinesRange() + self._saveLastEditLinesCmd(cmd, end - start + 1) + + _globalClipboard.value = self._qpart.lines[start:end + 1] + del self._qpart.lines[start:end + 1] + + def cmdInsertMode(self, cmd): + self.switchMode(Insert) + + def cmdJoinLines(self, cmd, repeatLineCount=None): + if repeatLineCount is not None: + self._selectRangeForRepeat(repeatLineCount) + + start, end = self._selectedLinesRange() + count = end - start + + if not count: # nothing to join + return + + self._saveLastEditLinesCmd(cmd, end - start + 1) + + cursor = QTextCursor(self._qpart.document().findBlockByNumber(start)) + with self._qpart: + for _ in range(count): + cursor.movePosition(QTextCursor.EndOfBlock) + cursor.movePosition(QTextCursor.NextCharacter, QTextCursor.KeepAnchor) + self.moveToFirstNonSpace(cursor, QTextCursor.KeepAnchor) + nonEmptyBlock = cursor.block().length() > 1 + cursor.removeSelectedText() + if nonEmptyBlock: + cursor.insertText(' ') + + self._qpart.setTextCursor(cursor) + + def cmdAppendAfterChar(self, cmd): + cursor = self._qpart.textCursor() + cursor.clearSelection() + cursor.movePosition(QTextCursor.Right) + self._qpart.setTextCursor(cursor) + self.switchMode(Insert) + + def cmdReplaceSelectedLines(self, cmd): + start, end = self._selectedLinesRange() + _globalClipboard.value = self._qpart.lines[start:end + 1] + + lastLineLen = len(self._qpart.lines[end]) + self._qpart.selectedPosition = ((start, 0), (end, lastLineLen)) + self._qpart.selectedText = '' + + self.switchMode(Insert) + + def cmdResetSelection(self, cmd): + self._qpart.cursorPosition = self._qpart.selectedPosition[0] + + def cmdInternalPaste(self, cmd): + if not _globalClipboard.value: + return + + with self._qpart: + cursor = self._qpart.textCursor() + + if self._selectLines: + start, end = self._selectedLinesRange() + del self._qpart.lines[start:end + 1] + else: + cursor.removeSelectedText() + + if isinstance(_globalClipboard.value, str): + self._qpart.textCursor().insertText(_globalClipboard.value) + elif isinstance(_globalClipboard.value, list): + currentLineIndex = self._qpart.cursorPosition[0] + text = '\n'.join(_globalClipboard.value) + index = currentLineIndex if self._selectLines else currentLineIndex + 1 + self._qpart.lines.insert(index, text) + + def cmdVisualMode(self, cmd): + if not self._selectLines: + self._resetSelection() + return # already in visual mode + + self.switchMode(Visual) + + def cmdVisualLinesMode(self, cmd): + if self._selectLines: + self._resetSelection() + return # already in visual lines mode + + self.switchMode(VisualLines) + + def cmdYank(self, cmd): + if self._selectLines: + start, end = self._selectedLinesRange() + _globalClipboard.value = self._qpart.lines[start:end + 1] + else: + _globalClipboard.value = self._qpart.selectedText + + self._qpart.copy() + + def cmdChange(self, cmd): + cursor = self._qpart.textCursor() + if cursor.selectedText(): + if self._selectLines: + _globalClipboard.value = cursor.selectedText().splitlines() + else: + _globalClipboard.value = cursor.selectedText() + cursor.removeSelectedText() + self.switchMode(Insert) + + def cmdUnIndent(self, cmd, repeatLineCount=None): + if repeatLineCount is not None: + self._selectRangeForRepeat(repeatLineCount) + else: + start, end = self._selectedLinesRange() + self._saveLastEditLinesCmd(cmd, end - start + 1) + + self._qpart._indenter.onChangeSelectedBlocksIndent(increase=False, withSpace=False) + + if repeatLineCount: + self._resetSelection(moveToTop=True) + + def cmdIndent(self, cmd, repeatLineCount=None): + if repeatLineCount is not None: + self._selectRangeForRepeat(repeatLineCount) + else: + start, end = self._selectedLinesRange() + self._saveLastEditLinesCmd(cmd, end - start + 1) + + self._qpart._indenter.onChangeSelectedBlocksIndent(increase=True, withSpace=False) + + if repeatLineCount: + self._resetSelection(moveToTop=True) + + def cmdAutoIndent(self, cmd, repeatLineCount=None): + if repeatLineCount is not None: + self._selectRangeForRepeat(repeatLineCount) + else: + start, end = self._selectedLinesRange() + self._saveLastEditLinesCmd(cmd, end - start + 1) + + self._qpart._indenter.onAutoIndentTriggered() + + if repeatLineCount: + self._resetSelection(moveToTop=True) + + _SIMPLE_COMMANDS = { + _A: cmdAppendAfterChar, + _c: cmdChange, + _C: cmdReplaceSelectedLines, + _d: cmdDelete, + _D: cmdDeleteLines, + _i: cmdInsertMode, + _J: cmdJoinLines, + _R: cmdReplaceSelectedLines, + _p: cmdInternalPaste, + _u: cmdResetSelection, + _x: cmdDelete, + _s: cmdChange, + _S: cmdReplaceSelectedLines, + _v: cmdVisualMode, + _V: cmdVisualLinesMode, + _X: cmdDeleteLines, + _y: cmdYank, + _Less: cmdUnIndent, + _Greater: cmdIndent, + _Equal: cmdAutoIndent, + } + + +class Visual(BaseVisual): + name = 'visual' + + _selectLines = False + + +class VisualLines(BaseVisual): + name = 'visual lines' + + _selectLines = True + + def __init__(self, *args): + BaseVisual.__init__(self, *args) + self._expandSelection() + + +class Normal(BaseCommandMode): + color = QColor('#33cc33') + name = 'normal' + + def _processChar(self): + ev = yield None + # Get action count + typedCount = 0 + + if ev.key() != _0: + char = ev.text() + while char.isdigit(): + digit = int(char) + typedCount = (typedCount * 10) + digit + ev = yield + char = ev.text() + + effectiveCount = typedCount or 1 + + # Now get the action + action = code(ev) + + if action in self._SIMPLE_COMMANDS: + cmdFunc = self._SIMPLE_COMMANDS[action] + cmdFunc(self, action, effectiveCount) + return True + elif action == _g: + ev = yield + if code(ev) == _g: + self._moveCursor('gg', 1) + + return True + elif action in (_f, _F, _t, _T): + ev = yield + if not isChar(ev): + return True + + searchChar = ev.text() + self._moveCursor(action, effectiveCount, searchChar=searchChar, select=False) + return True + elif action == _Period: # repeat command + if self._vim.lastEditCmdFunc is not None: + if typedCount: + self._vim.lastEditCmdFunc(typedCount) + else: + self._vim.lastEditCmdFunc() + return True + elif action in self._MOTIONS: + self._moveCursor(action, typedCount, select=False) + return True + elif action in self._COMPOSITE_COMMANDS: + moveCount = 0 + ev = yield + + if ev.key() != _0: # 0 is a command, not a count + char = ev.text() + while char.isdigit(): + digit = int(char) + moveCount = (moveCount * 10) + digit + ev = yield + char = ev.text() + + if moveCount == 0: + moveCount = 1 + + count = effectiveCount * moveCount + + # Get motion for a composite command + motion = code(ev) + searchChar = None + + if motion == _g: + ev = yield + if code(ev) == _g: + motion = 'gg' + else: + return True + elif motion in (_f, _F, _t, _T): + ev = yield + if not isChar(ev): + return True + + searchChar = ev.text() + + if (action != _z and motion in self._MOTIONS) or \ + (action, motion) in ((_d, _d), + (_y, _y), + (_Less, _Less), + (_Greater, _Greater), + (_Equal, _Equal), + (_z, _z)): + cmdFunc = self._COMPOSITE_COMMANDS[action] + cmdFunc(self, action, motion, searchChar, count) + + return True + elif isChar(ev): + return True # ignore unknown character + else: + return False # but do not ignore not-a-character keys + + assert 0 # must StopIteration on if + + def _repeat(self, count, func): + """ Repeat action 1 or more times. + If more than one - do it as 1 undoble action + """ + if count != 1: + with self._qpart: + for _ in range(count): + func() + else: + func() + + def _saveLastEditSimpleCmd(self, cmd, count): + def doCmd(count=count): + self._SIMPLE_COMMANDS[cmd](self, cmd, count) + + self._vim.lastEditCmdFunc = doCmd + + def _saveLastEditCompositeCmd(self, cmd, motion, searchChar, count): + def doCmd(count=count): + self._COMPOSITE_COMMANDS[cmd](self, cmd, motion, searchChar, count) + + self._vim.lastEditCmdFunc = doCmd + + # + # Simple commands + # + + def cmdInsertMode(self, cmd, count): + self.switchMode(Insert) + + def cmdInsertAtLineStartMode(self, cmd, count): + cursor = self._qpart.textCursor() + text = cursor.block().text() + spaceLen = len(text) - len(text.lstrip()) + cursor.setPosition(cursor.block().position() + spaceLen) + self._qpart.setTextCursor(cursor) + + self.switchMode(Insert) + + def cmdJoinLines(self, cmd, count): + cursor = self._qpart.textCursor() + if not cursor.block().next().isValid(): # last block + return + + with self._qpart: + for _ in range(count): + cursor.movePosition(QTextCursor.EndOfBlock) + cursor.movePosition(QTextCursor.NextCharacter, QTextCursor.KeepAnchor) + self.moveToFirstNonSpace(cursor, QTextCursor.KeepAnchor) + nonEmptyBlock = cursor.block().length() > 1 + cursor.removeSelectedText() + if nonEmptyBlock: + cursor.insertText(' ') + + if not cursor.block().next().isValid(): # last block + break + + self._qpart.setTextCursor(cursor) + + def cmdReplaceMode(self, cmd, count): + self.switchMode(Replace) + self._qpart.setOverwriteMode(True) + + def cmdReplaceCharMode(self, cmd, count): + self.switchMode(ReplaceChar) + self._qpart.setOverwriteMode(True) + + def cmdAppendAfterLine(self, cmd, count): + cursor = self._qpart.textCursor() + cursor.movePosition(QTextCursor.EndOfBlock) + self._qpart.setTextCursor(cursor) + self.switchMode(Insert) + + def cmdAppendAfterChar(self, cmd, count): + cursor = self._qpart.textCursor() + cursor.movePosition(QTextCursor.Right) + self._qpart.setTextCursor(cursor) + self.switchMode(Insert) + + def cmdUndo(self, cmd, count): + for _ in range(count): + self._qpart.undo() + + def cmdRedo(self, cmd, count): + for _ in range(count): + self._qpart.redo() + + def cmdNewLineBelow(self, cmd, count): + cursor = self._qpart.textCursor() + cursor.movePosition(QTextCursor.EndOfBlock) + self._qpart.setTextCursor(cursor) + self._repeat(count, self._qpart._insertNewBlock) + + self._saveLastEditSimpleCmd(cmd, count) + + self.switchMode(Insert) + + def cmdNewLineAbove(self, cmd, count): + cursor = self._qpart.textCursor() + + def insert(): + cursor.movePosition(QTextCursor.StartOfBlock) + self._qpart.setTextCursor(cursor) + self._qpart._insertNewBlock() + cursor.movePosition(QTextCursor.Up) + self._qpart._indenter.autoIndentBlock(cursor.block()) + + self._repeat(count, insert) + self._qpart.setTextCursor(cursor) + + self._saveLastEditSimpleCmd(cmd, count) + + self.switchMode(Insert) + + def cmdInternalPaste(self, cmd, count): + if not _globalClipboard.value: + return + + if isinstance(_globalClipboard.value, str): + cursor = self._qpart.textCursor() + if cmd == _p: + cursor.movePosition(QTextCursor.Right) + self._qpart.setTextCursor(cursor) + + self._repeat(count, + lambda: cursor.insertText(_globalClipboard.value)) + cursor.movePosition(QTextCursor.Left) + self._qpart.setTextCursor(cursor) + + elif isinstance(_globalClipboard.value, list): + index = self._qpart.cursorPosition[0] + if cmd == _p: + index += 1 + + self._repeat(count, + lambda: self._qpart.lines.insert(index, '\n'.join(_globalClipboard.value))) + + self._saveLastEditSimpleCmd(cmd, count) + + def cmdSubstitute(self, cmd, count): + """ s + """ + cursor = self._qpart.textCursor() + for _ in range(count): + cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor) + + if cursor.selectedText(): + _globalClipboard.value = cursor.selectedText() + cursor.removeSelectedText() + + self._saveLastEditSimpleCmd(cmd, count) + self.switchMode(Insert) + + def cmdSubstituteLines(self, cmd, count): + """ S + """ + lineIndex = self._qpart.cursorPosition[0] + availableCount = len(self._qpart.lines) - lineIndex + effectiveCount = min(availableCount, count) + + _globalClipboard.value = self._qpart.lines[lineIndex:lineIndex + effectiveCount] + with self._qpart: + del self._qpart.lines[lineIndex:lineIndex + effectiveCount] + self._qpart.lines.insert(lineIndex, '') + self._qpart.cursorPosition = (lineIndex, 0) + self._qpart._indenter.autoIndentBlock(self._qpart.textCursor().block()) + + self._saveLastEditSimpleCmd(cmd, count) + self.switchMode(Insert) + + def cmdVisualMode(self, cmd, count): + cursor = self._qpart.textCursor() + cursor.movePosition(QTextCursor.NextCharacter, QTextCursor.KeepAnchor) + self._qpart.setTextCursor(cursor) + self.switchMode(Visual) + + def cmdVisualLinesMode(self, cmd, count): + self.switchMode(VisualLines) + + def cmdDelete(self, cmd, count): + """ x + """ + cursor = self._qpart.textCursor() + direction = QTextCursor.Left if cmd == _X else QTextCursor.Right + for _ in range(count): + cursor.movePosition(direction, QTextCursor.KeepAnchor) + + if cursor.selectedText(): + _globalClipboard.value = cursor.selectedText() + cursor.removeSelectedText() + + self._saveLastEditSimpleCmd(cmd, count) + + def cmdDeleteUntilEndOfBlock(self, cmd, count): + """ C and D + """ + cursor = self._qpart.textCursor() + for _ in range(count - 1): + cursor.movePosition(QTextCursor.Down, QTextCursor.KeepAnchor) + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + _globalClipboard.value = cursor.selectedText() + cursor.removeSelectedText() + if cmd == _C: + self.switchMode(Insert) + + self._saveLastEditSimpleCmd(cmd, count) + + def cmdYankUntilEndOfLine(self, cmd, count): + oldCursor = self._qpart.textCursor() + cursor = self._qpart.textCursor() + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + _globalClipboard.value = cursor.selectedText() + self._qpart.setTextCursor(cursor) + self._qpart.copy() + self._qpart.setTextCursor(oldCursor) + + _SIMPLE_COMMANDS = {_A: cmdAppendAfterLine, + _a: cmdAppendAfterChar, + _C: cmdDeleteUntilEndOfBlock, + _D: cmdDeleteUntilEndOfBlock, + _i: cmdInsertMode, + _I: cmdInsertAtLineStartMode, + _J: cmdJoinLines, + _r: cmdReplaceCharMode, + _R: cmdReplaceMode, + _v: cmdVisualMode, + _V: cmdVisualLinesMode, + _o: cmdNewLineBelow, + _O: cmdNewLineAbove, + _p: cmdInternalPaste, + _P: cmdInternalPaste, + _s: cmdSubstitute, + _S: cmdSubstituteLines, + _u: cmdUndo, + _U: cmdRedo, + _x: cmdDelete, + _X: cmdDelete, + _Y: cmdYankUntilEndOfLine, + } + + # + # Composite commands + # + + def cmdCompositeDelete(self, cmd, motion, searchChar, count): + if motion in (_j, _Down): + lineIndex = self._qpart.cursorPosition[0] + availableCount = len(self._qpart.lines) - lineIndex + if availableCount < 2: # last line + return + + effectiveCount = min(availableCount, count) + + _globalClipboard.value = self._qpart.lines[lineIndex:lineIndex + effectiveCount + 1] + del self._qpart.lines[lineIndex:lineIndex + effectiveCount + 1] + elif motion in (_k, _Up): + lineIndex = self._qpart.cursorPosition[0] + if lineIndex == 0: # first line + return + + effectiveCount = min(lineIndex, count) + + _globalClipboard.value = self._qpart.lines[lineIndex - effectiveCount:lineIndex + 1] + del self._qpart.lines[lineIndex - effectiveCount:lineIndex + 1] + elif motion == _d: # delete whole line + lineIndex = self._qpart.cursorPosition[0] + availableCount = len(self._qpart.lines) - lineIndex + + effectiveCount = min(availableCount, count) + + _globalClipboard.value = self._qpart.lines[lineIndex:lineIndex + effectiveCount] + del self._qpart.lines[lineIndex:lineIndex + effectiveCount] + elif motion == _G: + currentLineIndex = self._qpart.cursorPosition[0] + _globalClipboard.value = self._qpart.lines[currentLineIndex:] + del self._qpart.lines[currentLineIndex:] + elif motion == 'gg': + currentLineIndex = self._qpart.cursorPosition[0] + _globalClipboard.value = self._qpart.lines[:currentLineIndex + 1] + del self._qpart.lines[:currentLineIndex + 1] + else: + self._moveCursor(motion, count, select=True, searchChar=searchChar) + + selText = self._qpart.textCursor().selectedText() + if selText: + _globalClipboard.value = selText + self._qpart.textCursor().removeSelectedText() + + self._saveLastEditCompositeCmd(cmd, motion, searchChar, count) + + def cmdCompositeChange(self, cmd, motion, searchChar, count): + # TODO deletion and next insertion should be undo-ble as 1 action + self.cmdCompositeDelete(cmd, motion, searchChar, count) + self.switchMode(Insert) + + def cmdCompositeYank(self, cmd, motion, searchChar, count): + oldCursor = self._qpart.textCursor() + if motion == _y: + cursor = self._qpart.textCursor() + cursor.movePosition(QTextCursor.StartOfBlock) + for _ in range(count - 1): + cursor.movePosition(QTextCursor.Down, QTextCursor.KeepAnchor) + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + self._qpart.setTextCursor(cursor) + _globalClipboard.value = [self._qpart.selectedText] + else: + self._moveCursor(motion, count, select=True, searchChar=searchChar) + _globalClipboard.value = self._qpart.selectedText + + self._qpart.copy() + self._qpart.setTextCursor(oldCursor) + + def cmdCompositeUnIndent(self, cmd, motion, searchChar, count): + if motion == _Less: + pass # current line is already selected + else: + self._moveCursor(motion, count, select=True, searchChar=searchChar) + self._expandSelection() + + self._qpart._indenter.onChangeSelectedBlocksIndent(increase=False, withSpace=False) + self._resetSelection(moveToTop=True) + + self._saveLastEditCompositeCmd(cmd, motion, searchChar, count) + + def cmdCompositeIndent(self, cmd, motion, searchChar, count): + if motion == _Greater: + pass # current line is already selected + else: + self._moveCursor(motion, count, select=True, searchChar=searchChar) + self._expandSelection() + + self._qpart._indenter.onChangeSelectedBlocksIndent(increase=True, withSpace=False) + self._resetSelection(moveToTop=True) + + self._saveLastEditCompositeCmd(cmd, motion, searchChar, count) + + def cmdCompositeAutoIndent(self, cmd, motion, searchChar, count): + if motion == _Equal: + pass # current line is already selected + else: + self._moveCursor(motion, count, select=True, searchChar=searchChar) + self._expandSelection() + + self._qpart._indenter.onAutoIndentTriggered() + self._resetSelection(moveToTop=True) + + self._saveLastEditCompositeCmd(cmd, motion, searchChar, count) + + def cmdCompositeScrollView(self, cmd, motion, searchChar, count): + if motion == _z: + self._qpart.centerCursor() + + _COMPOSITE_COMMANDS = {_c: cmdCompositeChange, + _d: cmdCompositeDelete, + _y: cmdCompositeYank, + _Less: cmdCompositeUnIndent, + _Greater: cmdCompositeIndent, + _Equal: cmdCompositeAutoIndent, + _z: cmdCompositeScrollView, + } diff --git a/doc/widgets.json b/doc/widgets.json index 3980d159205..350e7f1a6e6 100644 --- a/doc/widgets.json +++ b/doc/widgets.json @@ -240,7 +240,6 @@ "icon": "../Orange/widgets/data/icons/PythonScript.svg", "background": "#FFD39F", "keywords": [ - "file", "program", "function" ] diff --git a/requirements-gui.txt b/requirements-gui.txt index bfe7c8d15bd..0eb8d96df49 100644 --- a/requirements-gui.txt +++ b/requirements-gui.txt @@ -7,3 +7,4 @@ AnyQt>=0.0.11 pyqtgraph>=0.11.1 matplotlib>=2.0.0 +qtconsole>=4.7.2 diff --git a/tox.ini b/tox.ini index e60a8da18f6..5079057aec1 100644 --- a/tox.ini +++ b/tox.ini @@ -45,8 +45,8 @@ commands_pre = # freeze environment pip freeze commands = - coverage run {toxinidir}/quietunittest.py Orange.tests Orange.widgets.tests Orange.canvas.tests - coverage run {toxinidir}/quietunittest.py discover Orange.canvas.tests + coverage run -m unittest -v Orange.tests Orange.widgets.tests Orange.canvas.tests + coverage run -m unittest discover -v Orange.canvas.tests coverage combine coverage report