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:
- " +
- "
- ".join(map("in_{0}, in_{0}s".format, self.signal_names)) +
- "
Output variables:
- " +
- "
- ".join(map("out_{0}".format, self.signal_names)) +
- "
"
+ 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 = [
+ '', '',
+
+ '',
+ html.escape(item_completion).replace(' ', ' '),
+ ' | ',
+ ]
+ if item_type is not None:
+ parts.extend(['',
+ item_type,
+ ' | '
+ ])
+ parts.extend([
+ '
', '
',
+ ])
+
+ 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