diff --git a/Orange/canvas/__main__.py b/Orange/canvas/__main__.py
index f4c265918bc..809ebf5a1ba 100644
--- a/Orange/canvas/__main__.py
+++ b/Orange/canvas/__main__.py
@@ -337,7 +337,7 @@ def send_statistics(url):
r = requests.post(url, files={'file': json.dumps(data)})
if r.status_code != 200:
log.warning("Error communicating with server while attempting to send "
- "usage statistics. Status code " + str(r.status_code))
+ "usage statistics. Status code %d", r.status_code)
return
# success - wipe statistics file
log.info("Usage statistics sent.")
@@ -457,6 +457,20 @@ def main(argv=None):
app.setPalette(breeze_dark())
defaultstylesheet = "darkorange.qss"
+ # set pyqtgraph colors
+ def onPaletteChange():
+ p = app.palette()
+ bg = p.base().color().name()
+ fg = p.windowText().color().name()
+
+ log.info('Setting pyqtgraph background to %s', bg)
+ pyqtgraph.setConfigOption('background', bg)
+ log.info('Setting pyqtgraph foreground to %s', fg)
+ pyqtgraph.setConfigOption('foreground', fg)
+
+ app.paletteChanged.connect(onPaletteChange)
+ onPaletteChange()
+
palette = app.palette()
if style is None and palette.color(QPalette.Window).value() < 127:
log.info("Switching default stylesheet to darkorange")
@@ -560,6 +574,9 @@ def onrequest(url):
stylesheet_string = pattern.sub("", stylesheet_string)
+ if 'dark' in stylesheet:
+ app.setProperty('darkMode', True)
+
else:
log.info("%r style sheet not found.", stylesheet)
diff --git a/Orange/widgets/data/icons/pythonscript/add.svg b/Orange/widgets/data/icons/pythonscript/add.svg
new file mode 100644
index 00000000000..ddb7eeef500
--- /dev/null
+++ b/Orange/widgets/data/icons/pythonscript/add.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Orange/widgets/data/icons/pythonscript/class.svg b/Orange/widgets/data/icons/pythonscript/class.svg
new file mode 100644
index 00000000000..95b55868798
--- /dev/null
+++ b/Orange/widgets/data/icons/pythonscript/class.svg
@@ -0,0 +1,74 @@
+
+
diff --git a/Orange/widgets/data/icons/pythonscript/function.svg b/Orange/widgets/data/icons/pythonscript/function.svg
new file mode 100644
index 00000000000..ea1f4dd32d9
--- /dev/null
+++ b/Orange/widgets/data/icons/pythonscript/function.svg
@@ -0,0 +1,70 @@
+
+
diff --git a/Orange/widgets/data/icons/pythonscript/instance.svg b/Orange/widgets/data/icons/pythonscript/instance.svg
new file mode 100644
index 00000000000..f1e3af5994e
--- /dev/null
+++ b/Orange/widgets/data/icons/pythonscript/instance.svg
@@ -0,0 +1,70 @@
+
+
diff --git a/Orange/widgets/data/icons/pythonscript/keyword.svg b/Orange/widgets/data/icons/pythonscript/keyword.svg
new file mode 100644
index 00000000000..be890dfe22c
--- /dev/null
+++ b/Orange/widgets/data/icons/pythonscript/keyword.svg
@@ -0,0 +1,70 @@
+
+
diff --git a/Orange/widgets/data/icons/pythonscript/module.svg b/Orange/widgets/data/icons/pythonscript/module.svg
new file mode 100644
index 00000000000..6d5a77b292f
--- /dev/null
+++ b/Orange/widgets/data/icons/pythonscript/module.svg
@@ -0,0 +1,70 @@
+
+
diff --git a/Orange/widgets/data/icons/pythonscript/more.svg b/Orange/widgets/data/icons/pythonscript/more.svg
new file mode 100644
index 00000000000..bc0890de6f5
--- /dev/null
+++ b/Orange/widgets/data/icons/pythonscript/more.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Orange/widgets/data/icons/pythonscript/param.svg b/Orange/widgets/data/icons/pythonscript/param.svg
new file mode 100644
index 00000000000..8b8a4aac259
--- /dev/null
+++ b/Orange/widgets/data/icons/pythonscript/param.svg
@@ -0,0 +1,70 @@
+
+
diff --git a/Orange/widgets/data/icons/pythonscript/path.svg b/Orange/widgets/data/icons/pythonscript/path.svg
new file mode 100644
index 00000000000..8c7413f2c8d
--- /dev/null
+++ b/Orange/widgets/data/icons/pythonscript/path.svg
@@ -0,0 +1,70 @@
+
+
diff --git a/Orange/widgets/data/icons/pythonscript/property.svg b/Orange/widgets/data/icons/pythonscript/property.svg
new file mode 100644
index 00000000000..ae7c11f4214
--- /dev/null
+++ b/Orange/widgets/data/icons/pythonscript/property.svg
@@ -0,0 +1,70 @@
+
+
diff --git a/Orange/widgets/data/icons/pythonscript/restore.svg b/Orange/widgets/data/icons/pythonscript/restore.svg
new file mode 100644
index 00000000000..d20de3a162d
--- /dev/null
+++ b/Orange/widgets/data/icons/pythonscript/restore.svg
@@ -0,0 +1,57 @@
+
+
diff --git a/Orange/widgets/data/icons/pythonscript/save.svg b/Orange/widgets/data/icons/pythonscript/save.svg
new file mode 100644
index 00000000000..b32e097c8ef
--- /dev/null
+++ b/Orange/widgets/data/icons/pythonscript/save.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Orange/widgets/data/icons/pythonscript/statement.svg b/Orange/widgets/data/icons/pythonscript/statement.svg
new file mode 100644
index 00000000000..5e8a34afa5c
--- /dev/null
+++ b/Orange/widgets/data/icons/pythonscript/statement.svg
@@ -0,0 +1,69 @@
+
+
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 8b402599207..a712529197a 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -1,32 +1,46 @@
+import shutil
+import tempfile
+import uuid
+
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 jupyter_client import KernelManager
from typing import Optional, List, TYPE_CHECKING
+import pygments.style
+from pygments.token import Comment, Keyword, Number, String, Punctuation, Operator, Error, Name
+from qtconsole.pygments_highlighter import PygmentsHighlighter
+from qtconsole import styles
+from qtconsole.client import QtKernelClient
+from qtconsole.inprocess import QtInProcessKernelManager
+from qtconsole.manager import QtKernelManager
+
+
from AnyQt.QtWidgets import (
- QPlainTextEdit, QListView, QSizePolicy, QMenu, QSplitter, QLineEdit,
+ 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,
+ QKeySequence, QFontMetrics, QPainter
)
from AnyQt.QtCore import (
- Qt, QRegularExpression, QByteArray, QItemSelectionModel, QSize
+ Qt, QByteArray, QItemSelectionModel, QSize, QRectF, QTimer
)
+from Orange.widgets.data.utils.python_kernel import OrangeInProcessKernelManager
+from orangewidget.widget import Msg
+
from Orange.data import Table
from Orange.base import Learner, Model
-from Orange.util import interleave
from Orange.widgets import gui
+from Orange.widgets.data.utils.python_console import OrangeConsoleWidget
+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
@@ -66,290 +80,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)
-
- def insertFromMimeData(self, source):
- """
- Reimplemented from QPlainTextEdit.insertFromMimeData.
- """
- urls = source.urls()
- if urls:
- self.pasteFile(urls[0])
- else:
- super().insertFromMimeData(source)
-
- 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)
-
-
-class PythonConsole(QPlainTextEdit, code.InteractiveConsole):
- # `locals` is reasonably used as argument name
- # pylint: disable=redefined-builtin
- def __init__(self, locals=None, parent=None):
- QPlainTextEdit.__init__(self, parent)
- code.InteractiveConsole.__init__(self, locals)
- self.newPromptPos = 0
- self.history, self.historyInd = [""], 0
- self.loop = self.interact()
- next(self.loop)
-
- def setLocals(self, locals):
- self.locals = locals
-
- def updateLocals(self, locals):
- self.locals.update(locals)
-
- def interact(self, banner=None, _=None):
- try:
- sys.ps1
- except AttributeError:
- sys.ps1 = ">>> "
- try:
- sys.ps2
- except AttributeError:
- sys.ps2 = "... "
- cprt = ('Type "help", "copyright", "credits" or "license" '
- 'for more information.')
- if banner is None:
- self.write("Python %s on %s\n%s\n(%s)\n" %
- (sys.version, sys.platform, cprt,
- self.__class__.__name__))
- else:
- self.write("%s\n" % str(banner))
- more = 0
- while 1:
- try:
- if more:
- prompt = sys.ps2
- else:
- prompt = sys.ps1
- self.new_prompt(prompt)
- yield
- try:
- line = self.raw_input(prompt)
- except EOFError:
- self.write("\n")
- break
- else:
- more = self.push(line)
- except KeyboardInterrupt:
- self.write("\nKeyboardInterrupt\n")
- self.resetbuffer()
- more = 0
-
- def raw_input(self, prompt=""):
- input_str = str(self.document().lastBlock().previous().text())
- return input_str[len(prompt):]
-
- def new_prompt(self, prompt):
- self.write(prompt)
- self.newPromptPos = self.textCursor().position()
- self.repaint()
-
- def write(self, data):
- cursor = QTextCursor(self.document())
- cursor.movePosition(QTextCursor.End, QTextCursor.MoveAnchor)
- cursor.insertText(data)
- self.setTextCursor(cursor)
- self.ensureCursorVisible()
-
- def writelines(self, lines):
- for line in lines:
- self.write(line)
-
- def flush(self):
- pass
+ Number: '#66bb6a',
- def push(self, line):
- if self.history[0] != line:
- self.history.insert(0, line)
- self.historyInd = 0
-
- # prevent console errors to trigger error reporting & patch stdout, stderr
- with patch('sys.excepthook', sys.__excepthook__),\
- patch('sys.stdout', self),\
- patch('sys.stderr', self):
- return code.InteractiveConsole.push(self, line)
-
- def setLine(self, line):
- cursor = QTextCursor(self.document())
- cursor.movePosition(QTextCursor.End)
- cursor.setPosition(self.newPromptPos, QTextCursor.KeepAnchor)
- cursor.removeSelectedText()
- cursor.insertText(line)
- self.setTextCursor(cursor)
+ Operator: 'bold #aa22ff',
+ Operator.Word: 'bold #4caf50',
- def keyPressEvent(self, event):
- if event.key() == Qt.Key_Return:
- self.write("\n")
- next(self.loop)
- elif event.key() == Qt.Key_Up:
- self.historyUp()
- elif event.key() == Qt.Key_Down:
- self.historyDown()
- elif event.key() == Qt.Key_Tab:
- self.complete()
- elif event.key() in [Qt.Key_Left, Qt.Key_Backspace]:
- if self.textCursor().position() > self.newPromptPos:
- QPlainTextEdit.keyPressEvent(self, event)
- else:
- QPlainTextEdit.keyPressEvent(self, event)
+ Comment: 'italic #408080',
+ }
+}
- def historyUp(self):
- self.setLine(self.history[self.historyInd])
- self.historyInd = min(self.historyInd + 1, len(self.history) - 1)
- def historyDown(self):
- self.setLine(self.history[self.historyInd])
- self.historyInd = max(self.historyInd - 1, 0)
+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]}
+ )
- def complete(self):
- pass
- def _moveCursorToInputLine(self):
- """
- Move the cursor to the input line if not already there. If the cursor
- if already in the input line (at position greater or equal to
- `newPromptPos`) it is left unchanged, otherwise it is moved at the
- end.
-
- """
- cursor = self.textCursor()
- pos = cursor.position()
- if pos < self.newPromptPos:
- cursor.movePosition(QTextCursor.End)
- self.setTextCursor(cursor)
-
- def pasteCode(self, source):
- """
- Paste source code into the console.
- """
- self._moveCursorToInputLine()
-
- for line in interleave(source.splitlines(), itertools.repeat("\n")):
- if line != "\n":
- self.insertPlainText(line)
- else:
- self.write("\n")
- next(self.loop)
-
- def insertFromMimeData(self, source):
- """
- Reimplemented from QPlainTextEdit.insertFromMimeData.
- """
- if source.hasText():
- self.pasteCode(str(source.text()))
+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 Script:
@@ -419,7 +372,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"],
@@ -439,7 +392,7 @@ class Outputs:
signal_names = ("data", "learner", "classifier", "object")
- settings_version = 2
+ settings_version = 3
scriptLibrary: 'List[_ScriptData]' = Setting([{
"name": "Table from numpy",
"script": DEFAULT_SCRIPT,
@@ -449,35 +402,191 @@ 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)
+ useInProcessKernel = Setting(False)
+
+ class Warning(OWWidget.Warning):
+ illegal_var_type = Msg('{} should be of type {}, not {}.')
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(
+ int(textEditMargin), 0, 0, 0
+ )
+
+ self.editor = 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.editor.setTabStopWidth(4)
+
+ self.editor.modificationChanged[bool].connect(self.onModificationChanged)
+
+ # Console
+
+ self.consoleBox = gui.vBox(self, 'Console')
+ self.splitCanvas.addWidget(self.consoleBox)
+
+ # Qtconsole
+
+ jupyter_widget = OrangeConsoleWidget(
+ style_sheet=styles.default_light_style_sheet
+ )
+ jupyter_widget.results_ready.connect(self.receive_outputs)
+
+ jupyter_widget._highlighter.set_style(self.pygments_style_class)
+ jupyter_widget.font_family = defaultFont
+ jupyter_widget.font_size = defaultFontSize
+ jupyter_widget.reset_font()
+
+ self.console = jupyter_widget
+ self.consoleBox.layout().addWidget(self.console)
+ self.consoleBox.setAlignment(Qt.AlignBottom)
+ self.setAcceptDrops(True)
+
+ self.statuses = []
+
+ # 'Injecting variables...' is set in handleNewVars
+
+ @self.console.variables_finished_injecting.connect
+ def _():
+ self.clear_status('Injecting variables...')
+
+ @self.console.begun_collecting_variables.connect
+ def _():
+ self.set_status('Collecting variables...')
+
+ # 'Collecting variables...' is reset in receive_outputs
+
+ @self.console.execution_started.connect
+ def _():
+ self.set_status('Running script...', force=True)
+ # trigger console repaint
+ # (for some reason repaint is broken if not singleShotting)
+ QTimer.singleShot(0, self.console.update)
+
+ @self.console.execution_finished.connect
+ def _():
+ self.clear_status('Running script...')
+ # trigger console repaint
+ QTimer.singleShot(0, self.console.update)
+
+ # Kernel stuff
+
+ self.kernel_client: QtKernelClient = None
+ self.kernel_manager: KernelManager = None
+ self.init_kernel()
+
+ # Controls
+
+ self.editor_controls = gui.vBox(self.controlArea, box='Preferences')
+
+ # Vim
+
+ 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()
+
+ # Kernel type
+
+ gui.checkBox(
+ self.editor_controls, self, 'useInProcessKernel', 'Use in-process kernel',
+ tooltip="Avoids initializing data, but freezes Orange during computation.",
+ callback=self.init_kernel
+ )
+
+ # Library
+
+ self.libraryListSource = []
+ self._cachedDocuments = {}
self.libraryList = itemmodels.PyListModel(
[], self,
@@ -489,8 +598,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)
)
@@ -542,47 +650,27 @@ def __init__(self):
w.layout().setSpacing(1)
self.controlBox.layout().addWidget(w)
-
+ gui.rubber(self.controlArea)
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.run_action = QAction("Run script", self, triggered=self.commit,
+ shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_R))
+ self.addAction(self.run_action)
- self.text.modificationChanged[bool].connect(self.onModificationChanged)
-
- self.saveAction = action = QAction("&Save", self.text)
+ self.saveAction = action = QAction("&Save", self.editor)
action.setToolTip("Save script to file")
action.setShortcut(QKeySequence(QKeySequence.Save))
action.setShortcutContext(Qt.WidgetWithChildrenShortcut)
action.triggered.connect(self.saveScript)
- self.consoleBox = gui.vBox(self.splitCanvas, 'Console')
- self.console = PythonConsole({}, self)
- self.consoleBox.layout().addWidget(self.console)
- self.console.document().setDefaultFont(QFont(defaultFont))
- self.consoleBox.setAlignment(Qt.AlignBottom)
- self.splitCanvas.setSizes([2, 1])
- self.setAcceptDrops(True)
- self.controlArea.layout().addStretch(10)
+ # And finally,
+ self.splitCanvas.setSizes([2, 1])
self._restoreState()
self.settingsAboutToBePacked.connect(self._saveState)
def sizeHint(self) -> QSize:
- return super().sizeHint().expandedTo(QSize(800, 600))
+ return super().sizeHint().expandedTo(QSize(810, 600))
def _restoreState(self):
self.libraryListSource = [Script.fromdict(s) for s in self.scriptLibrary]
@@ -590,17 +678,61 @@ def _restoreState(self):
select_row(self.libraryView, self.currentScriptIndex)
if self.scriptText is not None:
- current = self.text.toPlainText()
+ current = self.editor.toPlainText()
# do not mark scripts as modified
if self.scriptText != current:
- self.text.document().setPlainText(self.scriptText)
+ self.editor.document().setPlainText(self.scriptText)
if self.splitterState is not None:
self.splitCanvas.restoreState(QByteArray(self.splitterState))
+ def init_kernel(self):
+ if self.kernel_manager is not None:
+ self.shutdown_kernel()
+
+ self._temp_connection_dir = tempfile.mkdtemp()
+
+ ident = str(uuid.uuid4()).split('-')[-1]
+ cf = os.path.join(self._temp_connection_dir, 'kernel-%s.json' % ident)
+
+ if self.useInProcessKernel:
+ self.kernel_manager = OrangeInProcessKernelManager(
+ connection_file=cf
+ )
+ else:
+ self.kernel_manager = QtKernelManager(
+ connection_file=cf
+ )
+
+ self.kernel_manager.start_kernel(
+ extra_arguments=[
+ '--IPKernelApp.kernel_class='
+ 'Orange.widgets.data.utils.python_kernel.OrangeIPythonKernel',
+ '--matplotlib='
+ 'inline'
+ ]
+ )
+ self.kernel_client = self.kernel_manager.client()
+ self.kernel_client.start_channels()
+
+ if self.editor is not None:
+ self.editor.kernel_manager = self.kernel_manager
+ self.editor.kernel_client = self.kernel_client
+ if self.console is not None:
+ self.console.set_in_process(self.useInProcessKernel)
+ self.console.kernel_manager = self.kernel_manager
+ self.console.kernel_client = self.kernel_client
+ self.console.set_kernel_id(ident)
+ self.update_variables_in_console()
+
+ def shutdown_kernel(self):
+ self.kernel_client.stop_channels()
+ self.kernel_manager.shutdown_kernel()
+ shutil.rmtree(self._temp_connection_dir)
+
def _saveState(self):
self.scriptLibrary = [s.asdict() for s in self.libraryListSource]
- self.scriptText = self.text.toPlainText()
+ self.scriptText = self.editor.toPlainText()
self.splitterState = bytes(self.splitCanvas.saveState())
def handle_input(self, obj, sig_id, signal):
@@ -611,6 +743,55 @@ def handle_input(self, obj, sig_id, signal):
else:
dic[sig_id] = obj
+ def clear_status(self, msg):
+ if msg not in self.statuses:
+ return
+ self.statuses.remove(msg)
+ self.__update_status()
+
+ def set_status(self, msg, force=False):
+ if msg in self.statuses:
+ if force:
+ self.statuses.remove(msg)
+ self.statuses.insert(0, msg)
+ return
+ if force:
+ self.statuses.insert(0, msg)
+ else:
+ self.statuses.append(msg)
+ self.__update_status()
+
+ def __update_status(self):
+ if self.statuses:
+ msg = self.statuses[0]
+ else:
+ msg = ''
+
+ self.setStatusMessage(msg)
+
+ def receive_outputs(self, out_vars):
+ self.clear_status('Collecting variables...')
+ self.progressBar()
+ for signal in self.signal_names:
+ out_name = "out_" + signal
+ req_type = self.Outputs.__dict__[signal].type
+
+ output = getattr(self.Outputs, signal)
+ if out_name not in out_vars:
+ output.send(None)
+ continue
+ var = out_vars[out_name]
+
+ if not isinstance(var, req_type):
+ output.send(None)
+ actual_type = type(var)
+ self.Warning.illegal_var_type(out_name,
+ req_type.__module__ + '.' + req_type.__name__,
+ actual_type.__module__ + '.' + actual_type.__name__)
+ continue
+
+ output.send(var)
+
@Inputs.data
def set_data(self, data, sig_id):
self.handle_input(data, sig_id, "data")
@@ -628,7 +809,16 @@ def set_object(self, data, sig_id):
self.handle_input(data, sig_id, "object")
def handleNewSignals(self):
- self.commit()
+ # update fake signature labels
+ self.func_sig.update_signal_text({
+ n: len(getattr(self, n)) for n in self.signal_names
+ })
+ self.update_variables_in_console()
+
+ def update_variables_in_console(self):
+ self.set_status('Injecting variables...')
+ vars = self.initial_locals_state()
+ self.console.set_vars(vars)
def selectedScriptIndex(self):
rows = self.libraryView.selectionModel().selectedRows()
@@ -641,7 +831,7 @@ def setSelectedScript(self, index):
select_row(self.libraryView, index)
def onAddScript(self, *_):
- self.libraryList.append(Script("New script", self.text.toPlainText(), 0))
+ self.libraryList.append(Script("New script", self.editor.toPlainText(), 0))
self.setSelectedScript(len(self.libraryList) - 1)
def onAddScriptFromFile(self, *_):
@@ -652,8 +842,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)
@@ -677,7 +866,7 @@ def onSelectedScriptChanged(self, selected, _deselected):
self.addNewScriptAction.trigger()
return
- self.text.setDocument(self.documentForScript(current))
+ self.editor.setDocument(self.documentForScript(current))
self.currentScriptIndex = current
def documentForScript(self, script=0):
@@ -688,7 +877,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
@@ -697,8 +888,8 @@ def documentForScript(self, script=0):
def commitChangesToLibrary(self, *_):
index = self.selectedScriptIndex()
if index is not None:
- self.libraryList[index].script = self.text.toPlainText()
- self.text.document().setModified(False)
+ self.libraryList[index].script = self.editor.toPlainText()
+ self.editor.document().setModified(False)
self.libraryList.emitDataChanged(index)
def onModificationChanged(self, modified):
@@ -710,8 +901,8 @@ def onModificationChanged(self, modified):
def restoreSaved(self):
index = self.selectedScriptIndex()
if index is not None:
- self.text.document().setPlainText(self.libraryList[index].script)
- self.text.document().setModified(False)
+ self.editor.document().setPlainText(self.libraryList[index].script)
+ self.editor.document().setModified(False)
def saveScript(self):
index = self.selectedScriptIndex()
@@ -736,46 +927,31 @@ def saveScript(self):
fn = filename
f = open(fn, 'w')
- f.write(self.text.toPlainText())
+ f.write(self.editor.toPlainText())
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())
- one_value = all_values[0] if len(all_values) == 1 else None
- d["in_" + name + "s"] = all_values
- d["in_" + name] = one_value
+ d[name + "s"] = all_values
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.Warning.clear()
self.Error.clear()
- lcls = self.initial_locals_state()
- lcls["_script"] = str(self.text.toPlainText())
- self.console.updateLocals(lcls)
- 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
- if not isinstance(out_var, signal_type) and out_var is not None:
- self.Error.add_message(signal,
- "'{}' has to be an instance of '{}'.".
- format(signal, signal_type.__name__))
- getattr(self.Error, signal)()
- out_var = None
- getattr(self.Outputs, signal).send(out_var)
+
+ script = str(self.editor.text)
+ self.console.run_script(script)
+
+ def keyPressEvent(self, event):
+ if event.matches(QKeySequence.InsertLineSeparator):
+ # run on Shift+Enter, Ctrl+Enter
+ self.run_action.trigger()
+ event.accept()
+ else:
+ super().keyPressEvent(event)
def dragEnterEvent(self, event): # pylint: disable=no-self-use
urls = event.mimeData().urls()
@@ -785,19 +961,22 @@ 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:
+ if version is None:
+ return
+ if version < 2 and 'libraryListSource' in settings:
scripts = settings.pop("libraryListSource") # type: List[Script]
library = [dict(name=s.name, script=s.script, filename=s.filename)
for s in scripts] # type: List[_ScriptData]
settings["scriptLibrary"] = library
+ elif version < 3: # qtconsole
+ settings['useInProcessKernel'] = True
+
+ def onDeleteWidget(self):
+ self.editor.terminate()
+ self.shutdown_kernel()
+ super().onDeleteWidget()
if __name__ == "__main__": # pragma: no cover
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 6c505737e76..62db91344ca 100644
--- a/Orange/widgets/data/tests/test_owpythonscript.py
+++ b/Orange/widgets/data/tests/test_owpythonscript.py
@@ -1,29 +1,34 @@
# Test methods with long descriptive names can omit docstrings
-# pylint: disable=missing-docstring
-import sys
-
+# pylint: disable=missing-docstring, unused-wildcard-import
+# pylint: disable=wildcard-import, protected-access
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
-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):
+ iris = Table("iris")
+ learner = LogisticRegressionLearner()
+ model = learner(iris)
+
def setUp(self):
self.widget = self.create_widget(OWPythonScript)
- self.iris = Table("iris")
- self.learner = LogisticRegressionLearner()
- self.model = self.learner(self.iris)
-
- def tearDown(self):
- # clear sys.last_*, these are set/used by interactive interpreter
- sys.last_type = sys.last_value = sys.last_traceback = None
- super().tearDown()
def test_inputs(self):
"""Check widget's inputs"""
@@ -37,114 +42,34 @@ def test_inputs(self):
self.send_signal(input_, None, 1)
self.assertEqual(getattr(self.widget, input_.lower()), {})
- def test_outputs(self):
- """Check widget's outputs"""
- for signal, data in (
- ("Data", self.iris),
- ("Learner", self.learner),
- ("Classifier", self.model)):
- lsignal = signal.lower()
- self.widget.text.setPlainText("out_{0} = in_{0}".format(lsignal))
- self.send_signal(signal, data, 1)
- self.assertIs(self.get_output(signal), data)
- self.send_signal(signal, None, 1)
- self.widget.text.setPlainText("print(in_{})".format(lsignal))
- self.widget.execute_button.click()
- self.assertIsNone(self.get_output(signal))
-
- def test_local_variable(self):
- """Check if variable remains in locals after removed from script"""
- self.widget.text.setPlainText("temp = 42\nprint(temp)")
- self.widget.execute_button.click()
- self.assertIn("42", self.widget.console.toPlainText())
- self.widget.text.setPlainText("print(temp)")
- self.widget.execute_button.click()
- self.assertNotIn("NameError: name 'temp' is not defined",
- self.widget.console.toPlainText())
-
- def test_wrong_outputs(self):
- """
- Error is shown when output variables are filled with wrong variable
- types and also output variable is set to None. (GH-2308)
- """
- self.assertEqual(len(self.widget.Error.active), 0)
- for signal, data in (
- ("Data", self.iris),
- ("Learner", self.learner),
- ("Classifier", self.model)):
- lsignal = signal.lower()
- self.send_signal(signal, data, 1)
- self.widget.text.setPlainText("out_{} = 42".format(lsignal))
- self.widget.execute_button.click()
- self.assertEqual(self.get_output(signal), None)
- self.assertTrue(hasattr(self.widget.Error, lsignal))
- self.assertTrue(getattr(self.widget.Error, lsignal).is_shown())
-
- self.widget.text.setPlainText("out_{0} = in_{0}".format(lsignal))
- self.widget.execute_button.click()
- self.assertIs(self.get_output(signal), data)
- self.assertFalse(getattr(self.widget.Error, lsignal).is_shown())
-
def test_owns_errors(self):
self.assertIsNot(self.widget.Error, OWWidget.Error)
- def test_multiple_signals(self):
- click = self.widget.execute_button.click
- console_locals = self.widget.console.locals
-
- titanic = Table("titanic")
-
- click()
- self.assertIsNone(console_locals["in_data"])
- self.assertEqual(console_locals["in_datas"], [])
-
- self.send_signal("Data", self.iris, 1)
- click()
- self.assertIs(console_locals["in_data"], self.iris)
- datas = console_locals["in_datas"]
- self.assertEqual(len(datas), 1)
- self.assertIs(datas[0], self.iris)
-
- self.send_signal("Data", titanic, 2)
- click()
- self.assertIsNone(console_locals["in_data"])
- self.assertEqual({id(obj) for obj in console_locals["in_datas"]},
- {id(self.iris), id(titanic)})
-
- self.send_signal("Data", None, 2)
- click()
- self.assertIs(console_locals["in_data"], self.iris)
- datas = console_locals["in_datas"]
- self.assertEqual(len(datas), 1)
- self.assertIs(datas[0], self.iris)
-
- self.send_signal("Data", None, 1)
- click()
- self.assertIsNone(console_locals["in_data"])
- self.assertEqual(console_locals["in_datas"], [])
-
def test_store_new_script(self):
- self.widget.text.setPlainText("42")
+ self.widget.editor.text = "42"
self.widget.onAddScript()
- script = self.widget.text.toPlainText()
+ script = self.widget.editor.toPlainText()
self.assertEqual("42", script)
def test_restore_from_library(self):
- before = self.widget.text.toPlainText()
- self.widget.text.setPlainText("42")
self.widget.restoreSaved()
- script = self.widget.text.toPlainText()
+ before = self.widget.editor.text
+ self.widget.editor.text = "42"
+ self.widget.restoreSaved()
+ script = self.widget.editor.text
self.assertEqual(before, script)
def test_store_current_script(self):
- self.widget.text.setPlainText("42")
+ self.widget.editor.text = "42"
settings = self.widget.settingsHandler.pack_data(self.widget)
- self.widget = self.create_widget(OWPythonScript)
- script = self.widget.text.toPlainText()
+ widget = self.create_widget(OWPythonScript)
+ script = widget.editor.text
self.assertNotEqual("42", script)
- self.widget = self.create_widget(OWPythonScript, stored_settings=settings)
- script = self.widget.text.toPlainText()
+ widget2 = self.create_widget(OWPythonScript, stored_settings=settings)
+ script = widget2.editor.text
self.assertEqual("42", script)
+ widget.onDeleteWidget()
+ widget2.onDeleteWidget()
def test_read_file_content(self):
with named_file("Content", suffix=".42") as fn:
@@ -158,25 +83,29 @@ def test_read_file_content(self):
self.assertIsNone(content)
def test_script_insert_mime_text(self):
- current = self.widget.text.toPlainText()
+ current = self.widget.editor.text
insert = "test\n"
- cursor = self.widget.text.cursor()
+ cursor = self.widget.editor.cursor()
cursor.setPos(0, 0)
mime = QMimeData()
mime.setText(insert)
- self.widget.text.insertFromMimeData(mime)
- self.assertEqual(insert + current, self.widget.text.toPlainText())
+ self.widget.editor.insertFromMimeData(mime)
+ self.assertEqual(insert + current, self.widget.editor.text)
def test_script_insert_mime_file(self):
with named_file("test", suffix=".42") as fn:
- previous = self.widget.text.toPlainText()
+ previous = self.widget.editor.text
mime = QMimeData()
url = QUrl.fromLocalFile(fn)
mime.setUrls([url])
- self.widget.text.insertFromMimeData(mime)
- self.assertEqual("test", self.widget.text.toPlainText())
- self.widget.text.undo()
- self.assertEqual(previous, self.widget.text.toPlainText())
+ self.widget.editor.insertFromMimeData(mime)
+ text = self.widget.editor.text.split("print('Hello world')")[0]
+ self.assertTrue(
+ "'" + fn + "'",
+ text
+ )
+ self.widget.editor.undo()
+ self.assertEqual(previous, self.widget.editor.text)
def test_dragEnterEvent_accepts_text(self):
with named_file("Content", suffix=".42") as fn:
@@ -201,52 +130,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")],
@@ -254,9 +137,258 @@ def test_migrate(self):
})
self.assertEqual(w.libraryListSource[0].name, "A")
+ def test_migrate_2(self):
+ w = self.create_widget(OWPythonScript, {
+ '__version__': 2
+ })
+ self.assertTrue(w.useInProcessKernel)
+
def test_restore(self):
w = self.create_widget(OWPythonScript, {
"scriptLibrary": [dict(name="A", script="1", filename=None)],
"__version__": 2
})
self.assertEqual(w.libraryListSource[0].name, "A")
+
+
+class TestKernel(WidgetTest):
+ iris = Table("iris")
+ learner = LogisticRegressionLearner()
+ model = learner(iris)
+
+ def setUp(self):
+ self.widget = self.create_widget(OWPythonScript)
+
+ def wait_execute_script(self, script=None):
+ """
+ Tests that invoke scripts take longer,
+ because they wait for the IPython kernel.
+ """
+ done = False
+
+ def results_ready_callback():
+ nonlocal done
+ done = True
+
+ def execution_finished_callback(success):
+ if not success:
+ nonlocal done
+ done = True
+
+ self.widget.console.execution_finished.connect(execution_finished_callback)
+ self.widget.console.results_ready.connect(results_ready_callback)
+
+ def is_done():
+ return done
+
+ if script is not None:
+ self.widget.editor.text = script
+ self.widget.execute_button.click()
+
+ def is_ready_and_clear():
+ return self.widget.console._OrangeConsoleWidget__is_ready and \
+ self.widget.console._OrangeConsoleWidget__queued_execution is None and \
+ not self.widget.console._OrangeConsoleWidget__executing and \
+ self.widget.console._OrangeConsoleWidget__queued_broadcast is None and \
+ not self.widget.console._OrangeConsoleWidget__broadcasting
+
+ if not is_ready_and_clear():
+ self.process_events(until=is_ready_and_clear, timeout=30000)
+ self.process_events(until=is_done)
+
+ self.widget.console.results_ready.disconnect(results_ready_callback)
+ self.widget.console.execution_finished.disconnect(execution_finished_callback)
+
+ def test_outputs(self):
+ """Check widget's outputs"""
+ # The type equation method for learners and classifiers probably isn't ideal,
+ # but it's something. The problem is that the console runs in a separate
+ # python process, so identity is broken when the objects are sent from
+ # process to process. If python3.8 shared memory is implemented for
+ # main process <-> IPython kernel communication,
+ # change this test back to checking identity equality.
+ for signal, data, assert_method in (
+ ("Data", self.iris, self.assert_table_equal),
+ ("Learner", self.learner, lambda a, b: self.assertEqual(type(a), type(b))),
+ ("Classifier", self.model, lambda a, b: self.assertEqual(type(a), type(b)))):
+ lsignal = signal.lower()
+ self.send_signal(signal, data, (1,))
+ self.wait_execute_script("out_{0} = in_{0}".format(lsignal))
+ assert_method(self.get_output(signal), data)
+ self.wait_execute_script("print(5)")
+ assert_method(self.get_output(signal), data)
+ self.send_signal(signal, None, (1,))
+ assert_method(self.get_output(signal), data)
+ self.wait_execute_script("print(5)")
+ self.assertIsNone(self.get_output(signal))
+
+ def test_local_variable(self):
+ """Check if variable remains in locals after removed from script"""
+ self.wait_execute_script("temp = 42\nprint(temp)")
+ self.assertIn('42', self.widget.console._control.toPlainText())
+
+ # after a successful execution, previous outputs are cleared
+ self.wait_execute_script("print(temp)")
+ self.assertNotIn("NameError: name 'temp' is not defined",
+ self.widget.console._control.toPlainText())
+
+ def test_wrong_outputs(self):
+ """
+ Warning is shown when output variables are filled with wrong variable
+ types and also output variable is set to None. (GH-2308)
+ """
+ # see comment in test_outputs()
+ for signal, data, assert_method in (
+ ("Data", self.iris, self.assert_table_equal),
+ ("Learner", self.learner, lambda a, b: self.assertEqual(type(a), type(b))),
+ ("Classifier", self.model, lambda a, b: self.assertEqual(type(a), type(b)))):
+ lsignal = signal.lower()
+ self.send_signal(signal, data, (1,))
+ self.wait_execute_script("out_{} = 42".format(lsignal))
+ assert_method(self.get_output(signal), None)
+ self.assertTrue(self.widget.Warning.illegal_var_type.is_shown())
+
+ self.wait_execute_script("out_{0} = in_{0}".format(lsignal))
+ assert_method(self.get_output(signal), data)
+ self.assertFalse(self.widget.Warning.illegal_var_type.is_shown())
+
+ def test_multiple_signals(self):
+ titanic = Table("titanic")
+
+ self.wait_execute_script('clear')
+
+ # if no data input signal, in_data is None
+ self.wait_execute_script("print(in_data)")
+ self.assertIn("None",
+ self.widget.console._control.toPlainText())
+
+ self.wait_execute_script('clear')
+
+ # if no data input signal, in_datas is empty list
+ self.wait_execute_script("print(in_datas)")
+ self.assertIn("[]",
+ self.widget.console._control.toPlainText())
+
+ self.wait_execute_script('clear')
+
+ # if one data input signal, in_data is iris
+ self.send_signal("Data", self.iris, (1,))
+ self.wait_execute_script("in_data")
+ self.assertIn(repr(self.iris),
+ self.widget.console._control.toPlainText())
+
+ self.wait_execute_script('clear')
+
+ # if one data input signal, in_datas is of len 1
+ self.wait_execute_script("'in_datas len: ' + str(len(in_datas))")
+ self.assertIn("in_datas len: 1",
+ self.widget.console._control.toPlainText())
+
+ self.wait_execute_script('clear')
+
+ # if two data input signals, in_data is defined
+ self.send_signal("Data", titanic, (2,))
+ self.wait_execute_script("print(in_data)")
+ self.assertNotIn("None",
+ self.widget.console._control.toPlainText())
+
+ self.wait_execute_script('clear')
+
+ # if two data input signals, in_datas is of len 2
+ self.wait_execute_script("'in_datas len: ' + str(len(in_datas))")
+ self.assertIn("in_datas len: 2",
+ self.widget.console._control.toPlainText())
+
+ self.wait_execute_script('clear')
+
+ # if two data signals, in_data == in_datas[0]
+ self.wait_execute_script('in_data == in_datas[0]')
+ self.assertIn("True",
+ self.widget.console._control.toPlainText())
+
+ self.wait_execute_script('clear')
+
+ # back to one data signal, in_data is titanic
+ self.send_signal("Data", None, (1,))
+
+ self.wait_execute_script("in_data")
+ self.assertIn(repr(titanic),
+ self.widget.console._control.toPlainText())
+
+ self.wait_execute_script('clear')
+
+ # back to one data signal after removing first signal, in_data == in_datas[0]
+ self.wait_execute_script('in_data == in_datas[0]')
+ self.assertIn("True",
+ self.widget.console._control.toPlainText())
+
+ self.wait_execute_script('clear')
+
+ # back to no data signal, in_data is None
+ self.send_signal("Data", None, (2,))
+
+ self.wait_execute_script("print(in_data)")
+ self.assertIn("None",
+ self.widget.console._control.toPlainText())
+
+ self.wait_execute_script('clear')
+
+ # back to no data signal, in_datas is undefined
+ self.wait_execute_script("print(in_datas)")
+ self.assertIn("[]",
+ self.widget.console._control.toPlainText())
+
+ def test_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)
+
+ self.send_signal(widget1.Inputs.data, self.iris, (1,), widget=widget1)
+ self.widget = widget1
+ self.wait_execute_script("x = 42")
+
+ self.widget = widget2
+ self.wait_execute_script("y = 2 * x")
+ self.assertIn("NameError: name 'x' is not defined",
+ self.widget.console._control.toPlainText())
+
+ def test_unreferencible(self):
+ self.wait_execute_script('out_object = 14')
+ self.assertEqual(self.get_output("Object"), 14)
+ self.wait_execute_script('out_object = ("a",14)')
+ self.assertEqual(self.get_output("Object"), ('a', 14))
+
+
+class TestInProcessKernel(TestKernel):
+ def setUp(self):
+ self.widget = self.create_widget(OWPythonScript,
+ stored_settings={'useInProcessKernel': True})
+
+ def test_namespaces(self):
+ """
+ Guess what, the ipykernel shell is a singleton :D This has the side
+ effect of not displaying 'Out' in any of the widgets except the last
+ created one (stdout still shows fine).
+ This test overrides the superclass test but really it tests for
+ shared namespaces. If you find a way to disable shared namespaces,
+ go for it my dude.
+ """
+ widget1 = self.create_widget(OWPythonScript, stored_settings={'useInProcessKernel': True})
+ widget2 = self.create_widget(OWPythonScript, stored_settings={'useInProcessKernel': True})
+
+ self.send_signal(widget1.Inputs.data, self.iris, (1,), widget=widget1)
+ self.widget = widget1
+ self.wait_execute_script("x = 42")
+
+ self.widget = widget2
+ self.wait_execute_script("x")
+ self.assertIn("42", self.widget.console._control.toPlainText())
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/Orange/widgets/data/utils/python_console.py b/Orange/widgets/data/utils/python_console.py
new file mode 100644
index 00000000000..bb62cde1364
--- /dev/null
+++ b/Orange/widgets/data/utils/python_console.py
@@ -0,0 +1,240 @@
+import codecs
+import logging
+
+import itertools
+import pickle
+import threading
+
+from AnyQt.QtCore import Qt, Signal
+from Orange.widgets.data.utils.python_kernel import update_kernel_vars, collect_kernel_vars
+from Orange.widgets.data.utils.python_serialize import OrangeZMQMixin
+from qtconsole.client import QtKernelClient
+from qtconsole.rich_jupyter_widget import RichJupyterWidget
+
+# Sometimes the comm's msg argument isn't used
+# pylint: disable=unused-argument
+# pylint being stupid? in_prompt is defined as a class var in JupyterWidget
+# pylint: disable=attribute-defined-outside-init
+
+log = logging.getLogger(__name__)
+
+
+class OrangeConsoleWidget(OrangeZMQMixin, RichJupyterWidget):
+ becomes_ready = Signal()
+
+ execution_started = Signal()
+
+ execution_finished = Signal(bool) # False for error
+
+ results_ready = Signal(dict)
+
+ begun_collecting_variables = Signal()
+
+ variables_finished_injecting = Signal()
+
+ def __init__(self, *args, style_sheet='', **kwargs):
+ super().__init__(*args, **kwargs)
+ self.__is_in_process = False
+ self.__is_starting_up = True
+ self.__is_ready = False
+
+ self.__queued_broadcast = None
+ self.__queued_execution = None
+ self.__prompt_num = 1
+ self.__default_in_prompt = self.in_prompt
+ self.__executing = False
+ self.__broadcasting = False
+ self.__threads = []
+
+ self.style_sheet = style_sheet + \
+ '.run-prompt { color: #aa22ff; }'
+
+ self.becomes_ready.connect(self.__on_ready)
+
+ def __on_ready(self):
+ # Let the widget/kernel start up before trying to run a script,
+ # by storing a queued execution payload when the widget's commit
+ # method is invoked before appears.
+ if self.__is_starting_up:
+ self.__is_starting_up = False
+ if not self.is_in_process():
+ self.init_client()
+ self.__is_ready = True
+ self.__run_queued_broadcast()
+ self.__run_queued_payload()
+
+ def __run_queued_broadcast(self):
+ if not self.__is_ready or self.__queued_broadcast is None:
+ return
+ qb = self.__queued_broadcast
+ self.__queued_broadcast = None
+ self.set_vars(*qb)
+
+ def __run_queued_payload(self):
+ if not self.__is_ready or self.__queued_execution is None:
+ return
+ qe = self.__queued_execution
+ self.__queued_execution = None
+ self.run_script(*qe)
+
+ def set_in_process(self, enabled):
+ if self.__is_in_process == enabled:
+ return
+ self.__is_in_process = enabled
+ self.__is_ready = False
+ self.__executing = False
+ self.__broadcasting = False
+ self.__prompt_num = 1
+
+ self.__is_starting_up = True
+
+ def is_in_process(self):
+ return self.__is_in_process
+
+ def run_script(self, script):
+ """
+ Inject the in vars, run the script,
+ collect the out vars (emit the results_ready signal).
+ """
+ if not self.__is_ready:
+ self.__queued_execution = (script, )
+ return
+
+ if self.__executing or self.__broadcasting or \
+ (self.is_in_process() and self.kernel_manager.kernel is None):
+ self.__queued_execution = (script, )
+ self.__is_ready = False
+ if self.__executing:
+ self.interrupt_kernel()
+ return
+
+ # run the script
+ self.__executing = True
+ log.debug('Running script')
+ # update prompts
+ self._set_input_buffer('')
+ self.in_prompt = '' \
+ 'Run[%i]' \
+ ''
+ self._update_prompt(self.__prompt_num)
+ self._append_plain_text('\n')
+ self.in_prompt = 'Running script...'
+ self._show_interpreter_prompt(self.__prompt_num)
+
+ self.execution_started.emit()
+ # we abuse this method instead of others to keep
+ # the 'Running script...' prompt at the bottom of the console
+ self.kernel_client.execute(script)
+
+ def set_vars(self, vars):
+ if not self.__is_ready:
+ self.__queued_broadcast = (vars, )
+ return
+
+ if self.__executing or self.__broadcasting or \
+ (self.is_in_process() and self.kernel_manager.kernel is None):
+ self.__is_ready = False
+ self.__queued_broadcast = (vars, )
+ return
+
+ self.__broadcasting = True
+
+ self.in_prompt = "Injecting variables..."
+ self._update_prompt(self.__prompt_num)
+
+ if self.is_in_process():
+ kernel = self.kernel_manager.kernel
+ update_kernel_vars(kernel, vars, self.signals)
+ self.on_variables_injected()
+ else:
+ super().set_vars(vars)
+
+ def on_variables_injected(self):
+ log.debug('Cleared injecting variables')
+ self.__broadcasting = False
+ self.in_prompt = self.__default_in_prompt
+ self._update_prompt(self.__prompt_num)
+
+ self.variables_finished_injecting.emit()
+
+ if not self.__is_ready:
+ self.becomes_ready.emit()
+
+ def on_start_collecting_vars(self):
+ log.debug('Collecting variables...')
+
+ # the prompt isn't updated to reflect this,
+ # but the widget should show that variables are being collected
+
+ # self.in_prompt = 'Collecting variables...'
+ # self._update_prompt(self.__prompt_num)
+ self.begun_collecting_variables.emit()
+
+ def handle_new_vars(self, vardict):
+ varlists = {
+ 'out_' + name[:-1]: vs[0]
+ for name, vs in vardict.items()
+ if len(vs) > 0
+ }
+
+ self.results_ready.emit(varlists)
+
+ # override
+ def _handle_execute_result(self, msg):
+ super()._handle_execute_result(msg)
+ if self.__executing:
+ self._append_plain_text('\n', before_prompt=True)
+
+ # override
+ def _handle_execute_reply(self, msg):
+ if 'execution_count' in msg['content']:
+ self.__prompt_num = msg['content']['execution_count'] + 1
+
+ if not self.__executing:
+ super()._handle_execute_reply(msg)
+ return
+
+ self.__executing = False
+ self.in_prompt = self.__default_in_prompt
+
+ if msg['content']['status'] != 'ok':
+ self.execution_finished.emit(False)
+ self._show_interpreter_prompt(self.__prompt_num)
+ super()._handle_execute_reply(msg)
+ return
+
+ self._update_prompt(self.__prompt_num)
+ self.execution_finished.emit(True)
+
+ # collect variables manually, handle_new_vars will not be called
+ if self.is_in_process():
+ kernel = self.kernel_manager.kernel
+ self.results_ready.emit(
+ collect_kernel_vars(kernel, self.signals)
+ )
+
+ # override
+ def _handle_kernel_died(self, since_last_heartbeat):
+ super()._handle_kernel_died(since_last_heartbeat)
+ self.__is_ready = False
+
+ # override
+ def _show_interpreter_prompt(self, number=None):
+ """
+ The console's ready when the prompt shows up.
+ """
+ super()._show_interpreter_prompt(number)
+ if number is not None and not self.__is_ready:
+ self.becomes_ready.emit()
+
+ # override
+ def _event_filter_console_keypress(self, event):
+ """
+ KeyboardInterrupt on run script.
+ """
+ if self._control_key_down(event.modifiers(), include_command=False) and \
+ event.key() == Qt.Key_C and \
+ self.__executing:
+ self.interrupt_kernel()
+ return True
+ return super()._event_filter_console_keypress(event)
diff --git a/Orange/widgets/data/utils/python_kernel.py b/Orange/widgets/data/utils/python_kernel.py
new file mode 100644
index 00000000000..fc65d2b5bee
--- /dev/null
+++ b/Orange/widgets/data/utils/python_kernel.py
@@ -0,0 +1,86 @@
+# Watch what you import in this file,
+# it may hang kernel on startup
+from collections import defaultdict
+
+from ipykernel.inprocess.ipkernel import InProcessKernel
+from ipykernel.iostream import OutStream
+from ipykernel.ipkernel import IPythonKernel
+from qtconsole.inprocess import QtInProcessKernelManager
+from traitlets import default
+
+from Orange.widgets.data.utils.python_serialize import OrangeZMQMixin
+
+# Sometimes the comm's msg argument isn't used
+# pylint: disable=unused-argument
+
+
+class OrangeIPythonKernel(OrangeZMQMixin, IPythonKernel):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.variables = {}
+ self.init_comms_kernel()
+
+ def handle_new_vars(self, vars):
+ input_vars = update_kernel_vars(self, vars, self.signals)
+ self.variables.update(input_vars)
+
+ async def execute_request(self, *args, **kwargs):
+ await super().execute_request(*args, **kwargs)
+ if not self.is_initialized():
+ return
+
+ variables = collect_kernel_vars(self, self.signals)
+ prepared_variables = {
+ k[4:] + 's': [v]
+ for k, v in variables.items()
+ }
+ self.set_vars(prepared_variables)
+
+
+def update_kernel_vars(kernel, vars, signals):
+ input_vars = {}
+
+ for signal in signals:
+ # remove old out_ vars
+ out_name = 'out_' + signal
+ if out_name in kernel.shell.user_ns:
+ del kernel.shell.user_ns[out_name]
+ kernel.shell.user_ns_hidden.pop(out_name, None)
+
+ if signal + 's' in vars and vars[signal + 's']:
+ input_vars['in_' + signal + 's'] = vars[signal + 's']
+
+ # prepend script to set single signal values,
+ # e.g. in_data = in_datas[0]
+ input_vars['in_' + signal] = input_vars['in_' + signal + 's'][0]
+ else:
+ input_vars['in_' + signal] = None
+ input_vars['in_' + signal + 's'] = []
+ kernel.shell.push(input_vars)
+ return input_vars
+
+
+def collect_kernel_vars(kernel, signals):
+ variables = {}
+ for signal in signals:
+ name = 'out_' + signal
+ if name in kernel.shell.user_ns:
+ var = kernel.shell.user_ns[name]
+ variables[name] = var
+ return variables
+
+
+class OrangeInProcessKernel(InProcessKernel):
+ @default('stdout')
+ def _default_stdout(self):
+ return OutStream(self.session, self.iopub_thread, 'stdout', watchfd=False)
+
+ @default('stderr')
+ def _default_stderr(self):
+ return OutStream(self.session, self.iopub_thread, 'stderr', watchfd=False)
+
+
+class OrangeInProcessKernelManager(QtInProcessKernelManager):
+ def start_kernel(self, **kwds):
+ self.kernel = OrangeInProcessKernel(parent=self, session=self.session)
diff --git a/Orange/widgets/data/utils/python_serialize.py b/Orange/widgets/data/utils/python_serialize.py
new file mode 100644
index 00000000000..0e927523d2c
--- /dev/null
+++ b/Orange/widgets/data/utils/python_serialize.py
@@ -0,0 +1,401 @@
+import sys
+from _weakref import ref
+
+import logging
+import pickle
+import zlib
+from collections import defaultdict
+
+import threading
+
+from weakref import WeakValueDictionary, WeakKeyDictionary
+
+import numpy
+import zmq
+
+# avoid import Qt here, it'll slow down kernel startup considerably
+
+log = logging.getLogger(__name__)
+
+
+class SerializingSocket(zmq.Socket):
+ """A class with some extra serialization methods
+ send_zipped_pickle is just like send_pyobj, but uses
+ zlib to compress the stream before sending.
+ send_array sends numpy arrays with metadata necessary
+ for reconstructing the array on the other side (dtype,shape).
+ """
+
+ def send_zipped_pickle(self, obj, flags=0, protocol=-1):
+ """pack and compress an object with pickle and zlib."""
+ pobj = pickle.dumps(obj, protocol)
+ zobj = zlib.compress(pobj)
+ log.info('zipped pickle is %i bytes' % len(zobj))
+ return self.send(zobj, flags=flags)
+
+ def recv_zipped_pickle(self, flags=0):
+ """reconstruct a Python object sent with zipped_pickle"""
+ zobj = self.recv(flags)
+ pobj = zlib.decompress(zobj)
+ return pickle.loads(pobj)
+
+ def send_array(self, A, flags=0, copy=True, track=False):
+ """send a numpy array with metadata"""
+ md = {
+ 'dtype': str(A.dtype),
+ 'shape': A.shape,
+ }
+ self.send_json(md, flags | zmq.SNDMORE)
+ return self.send(A, flags, copy=copy, track=track)
+
+ def recv_array(self, flags=0, copy=True, track=False):
+ """recv a numpy array"""
+ md = self.recv_json(flags=flags)
+ msg = self.recv(flags=flags, copy=copy, track=track)
+ buf = memoryview(msg) # TYL
+ A = numpy.frombuffer(buf, dtype=md['dtype'])
+ return A.reshape(md['shape'])
+
+ # def send_table(self, T, flags=0, copy=True, track=False):
+ #
+ # def recv_table(self, flags=0, copy=True, track=False):
+ # kwargs = {
+ # arrname: self.recv_array(comm, flags, copy, track)
+ # for arrname, comm in table['arraycomms'].items()
+ # }
+ # kwargs['domain'] = deserialize_object(table['domain'])
+ # kwargs['attributes'] = deserialize_object(table['attributes'])
+ # from Orange.data import Table
+ # return Table.from_numpy(**kwargs)
+
+ def send_vars(self, variables, flags=0, copy=True, track=False):
+ vars = defaultdict(list)
+ vars.update(variables)
+
+ tables = vars['datas']
+ models = vars['classifiers']
+ learners = vars['learners']
+ objects = vars['objects']
+
+ # all of this is sent only once the non-multipart send_string initiates
+
+ # introduce receiver to vars
+ self.send_json(
+ {
+ vn: [v[0] for v in vs]
+ for vn, vs in vars.items()
+ },
+ flags=flags | zmq.SNDMORE
+ )
+ # send vars
+ for t in [t[1] for t in tables]:
+ self.send_zipped_pickle(t)
+ for m in [m[1] for m in models]:
+ self.send_zipped_pickle(m)
+ for l in [l[1] for l in learners]:
+ self.send_zipped_pickle(l)
+ for o in [o[1] for o in objects]:
+ self.send_zipped_pickle(o)
+ # initiate multipart msg
+ self.send_string('')
+
+ def recv_vars(self, flags=0, copy=True, track=False):
+ spec = self.recv_json(flags)
+ tables = [
+ (i, self.recv_zipped_pickle())
+ for i in spec['datas']
+ ]
+ models = [
+ (i, self.recv_zipped_pickle())
+ for i in spec['classifiers']
+ ]
+ learners = [
+ (i, self.recv_zipped_pickle())
+ for i in spec['learners']
+ ]
+ objects = [
+ (i, self.recv_zipped_pickle())
+ for i in spec['objects']
+ ]
+ self.recv_string()
+
+ return {
+ 'datas': tables,
+ 'classifiers': models,
+ 'learners': learners,
+ 'objects': objects
+ }
+
+
+class SerializingContext(zmq.Context):
+ _socket_class = SerializingSocket
+
+
+class OrangeZMQMixin:
+
+ signals = ('data', 'learner', 'classifier', 'object')
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.is_ipc = 'win32' not in sys.platform
+ self.__kernel_id = None
+
+ self.__threads = []
+ self.__my_vars_for_id = WeakKeyDictionary()
+ self.__received_vars_by_id = WeakValueDictionary()
+ self.__my_vars = {} # e.g., {'tables': [(5, table_object)], ...}
+ self.__held_vars = {} # e.g., {'tables': [(5, table_object)], ...}
+ self.__last_id = 0
+
+ self.init_comm = None
+ self.broadcast_comm = None
+ self.request_comm = None
+
+ self.ctx = SerializingContext()
+ self.ctx.setsockopt(zmq.RCVTIMEO, 300000)
+ self.ctx.setsockopt(zmq.SNDTIMEO, 300000)
+
+ self.socket = self.ctx.socket(zmq.PAIR)
+
+ # Abstract interface
+
+ def handle_new_vars(self, vars):
+ raise NotImplementedError
+
+ def on_variables_injected(self):
+ pass
+
+ def on_start_collecting_vars(self):
+ pass
+
+ # Methods
+
+ def set_kernel_id(self, kernel_id, kernel=False):
+ if self.__kernel_id == kernel_id:
+ return
+ self.__kernel_id = kernel_id
+
+ self.__my_vars_for_id = WeakKeyDictionary()
+ self.__received_vars_by_id = WeakValueDictionary()
+ self.__my_vars = {} # e.g., {'tables': [(5, table_object)], ...}
+ self.__held_vars = {} # e.g., {'tables': [(5, table_object)], ...}
+ self.socket = self.ctx.socket(zmq.PAIR)
+
+ if kernel:
+ self.init_socket_kernel()
+ elif self.init_comm is not None:
+ self.init_comm.send({
+ 'id': kernel_id
+ })
+
+ def set_vars(self, vars):
+ self.__my_vars = self.__identify_vars(vars)
+ varspec = {
+ name: [i for i, _ in vs]
+ for name, vs in self.__my_vars.items()
+ }
+ self.sync_vars(varspec)
+
+ def sync_vars(self, varspec):
+ if self.broadcast_comm is not None:
+ self.__broadcast_vars(varspec)
+
+ def is_initialized(self):
+ return self.__kernel_id is not None
+
+ def init_socket_kernel(self):
+ if self.is_ipc:
+ self.socket.bind('ipc://' + self.__kernel_id)
+ else:
+ port = self.socket.bind_to_random_port('tcp://127.0.0.1')
+ self.init_comm.send({
+ 'port': str(port)
+ })
+
+ def init_comms_kernel(self):
+ def comm_init(comm_name, callback):
+ def assign_comm(comm, _):
+ setattr(self, comm_name, comm)
+ comm.on_msg(callback)
+ return assign_comm
+
+ self.comm_manager.register_target(
+ 'request_comm',
+ comm_init(
+ 'request_comm',
+ lambda msg: self.__on_comm_request(msg)
+ )
+ )
+ self.comm_manager.register_target(
+ 'broadcast_comm',
+ comm_init(
+ 'broadcast_comm',
+ lambda msg: self.__on_comm_broadcast(msg)
+ )
+ )
+ self.comm_manager.register_target(
+ 'init_comm',
+ comm_init(
+ 'init_comm',
+ lambda msg: self.__on_comm_init(msg)
+ )
+ )
+
+ def init_client(self):
+ self.request_comm = self.kernel_client.comm_manager.new_comm(
+ 'request_comm', {}
+ )
+ self.request_comm.on_msg(self.__on_comm_request)
+ self.broadcast_comm = self.kernel_client.comm_manager.new_comm(
+ 'broadcast_comm', {}
+ )
+ self.broadcast_comm.on_msg(self.__on_comm_broadcast)
+ self.init_comm = self.kernel_client.comm_manager.new_comm(
+ 'init_comm', {}
+ )
+ if self.is_ipc:
+ self.socket.connect('ipc://' + self.__kernel_id)
+ else:
+ # ipc is not supported on windows, so kernel needs to let us know tcp port after making first handshake
+ self.init_comm.on_msg(self.__on_comm_init)
+ if self.__kernel_id is not None:
+ self.init_comm.send({
+ 'id': self.__kernel_id
+ })
+
+ # Private parts
+
+ def __on_comm_broadcast(self, msg):
+ varspec = msg['content']['data']['varspec']
+
+ self.__held_vars = {
+ name: [
+ (i, self.__received_vars_by_id.get(i, None))
+ for i in is_
+ ]
+ for name, is_ in varspec.items()
+ }
+
+ missing_ids = []
+ for name, vs in self.__held_vars.items():
+ for i, var in vs:
+ if var is None:
+ missing_ids.append(i)
+
+ if missing_ids:
+ self.__recv_vars(
+ callback=self.__finalize_vars
+ )
+ msg = {
+ 'status': 'missing',
+ 'var_ids': missing_ids
+ }
+ self.on_start_collecting_vars()
+ else:
+ msg = {
+ 'status': 'ok'
+ }
+ self.__finalize_vars(self.__held_vars)
+ self.request_comm.send(msg)
+
+ def __on_comm_request(self, msg):
+ if msg['content']['data']['status'] == 'ok':
+ self.on_variables_injected()
+ else:
+ var_ids = msg['content']['data']['var_ids']
+ payload = {
+ name: [
+ v for v in vs
+ if v[0] in var_ids
+ ]
+ for name, vs in self.__my_vars.items()
+ }
+ self.__send_vars(payload)
+
+ def __on_comm_init(self, msg):
+ data = msg['content']['data']
+ if 'id' in data:
+ # client sending the kernel the id
+ i = data['id']
+ self.set_kernel_id(i, kernel=True)
+ elif 'port' in data:
+ # (windows-only) kernel sending tcp port to client
+ port = data['port']
+ self.socket.connect('tcp://127.0.0.1:' + port)
+ else:
+ raise Exception('Invalid comm_init msg')
+
+ def __identify_vars(self, vars):
+ vars_with_ids = defaultdict(list)
+ for name, vs in vars.items():
+ for var in vs:
+
+ # if the object is not weak referencible,
+ # it's going to be copied each time
+ try:
+ ref(var)
+ except TypeError:
+ new_id = self.__new_id()
+ vars_with_ids[name].append((new_id, var))
+ continue
+
+ i = self.__my_vars_for_id.get(var, None)
+ if i is not None:
+ vars_with_ids[name].append((i, var))
+ else:
+ new_id = self.__new_id()
+ self.__my_vars_for_id[var] = new_id
+ vars_with_ids[name].append((new_id, var))
+ return vars_with_ids
+
+ def __finalize_vars(self, vars):
+ self.__held_vars.update(vars)
+
+ var_objs = {
+ k: [v[1] for v in vs]
+ for k, vs in self.__held_vars.items()
+ }
+
+ self.handle_new_vars(var_objs)
+ self.request_comm.send({
+ 'status': 'ok'
+ })
+
+ def __send_vars(self, vars, callback=lambda *_: None):
+ self.__run_thread_with_callback(
+ self.socket.send_vars,
+ (vars, ),
+ callback
+ )
+
+ def __recv_vars(self, callback=lambda *_: None):
+ self.__run_thread_with_callback(
+ self.socket.recv_vars,
+ (),
+ callback
+ )
+
+ def __broadcast_vars(self, varspec):
+ self.broadcast_comm.send({
+ 'varspec': varspec
+ })
+
+ def __run_thread_with_callback(self, target, args, callback):
+
+ def target_and_callback():
+ result = target(*args)
+ self.__threads.remove(thread)
+ if result is not None:
+ callback(result)
+ else:
+ callback()
+
+ thread = threading.Thread(
+ target=target_and_callback
+ )
+ self.__threads.append(thread)
+ thread.start()
+
+ def __new_id(self):
+ self.__last_id += 1
+ return self.__last_id
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..dc71046c681
--- /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.
+"""
+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..3fe662d3698
--- /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.
+"""
+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..d78f4f680f9
--- /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.
+"""
+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..0cc3e385e32
--- /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.
+"""
+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..6dded0fdbfd
--- /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.
+"""
+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..1dbc7a9a886
--- /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.
+"""
+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..74e46314364
--- /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.
+"""
+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..4af8acfc7d8
--- /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.
+"""
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..996a67d56f9
--- /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.
+"""
+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..38c845a9e1b
--- /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.
+"""
+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..2e03031c578
--- /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.
+"""
+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..15b52a5140e
--- /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.
+"""
+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/Orange/widgets/utils/plot/owpalette.py b/Orange/widgets/utils/plot/owpalette.py
index 9c58c29947c..19112fa1570 100644
--- a/Orange/widgets/utils/plot/owpalette.py
+++ b/Orange/widgets/utils/plot/owpalette.py
@@ -5,8 +5,6 @@
__all__ = ["create_palette", "OWPalette"]
-pg.setConfigOption('background', 'w')
-pg.setConfigOption('foreground', 'k')
pg.setConfigOptions(antialias=True)
diff --git a/doc/widgets.json b/doc/widgets.json
index bdd57b39425..d8d3f8c7b59 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 ab2e9ec87ee..d59986a374f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -22,6 +22,8 @@ setenv =
# Need this otherwise unittest installs a warning filter that overrides
# our desire to have OrangeDeprecationWarnings raised
PYTHONWARNINGS=module
+ # trace memory allocations
+ PYTHONTRACEMALLOC=1
# Skip loading of example workflows as that inflates coverage
SKIP_EXAMPLE_WORKFLOWS=True
# set coverage output and project config
@@ -45,8 +47,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