From 7ff1b7fa097e01cba126c50cee4e5f4e486d8a56 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Wed, 14 Jul 2021 23:50:42 +0100
Subject: [PATCH 01/44] __main__: Add darkMode property to QApplication
---
Orange/canvas/__main__.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/Orange/canvas/__main__.py b/Orange/canvas/__main__.py
index f4c265918bc..d374c52e7da 100644
--- a/Orange/canvas/__main__.py
+++ b/Orange/canvas/__main__.py
@@ -560,6 +560,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)
From f0fc49ec028053fab9c0bbe0865afe36de2c2324 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Thu, 18 Feb 2021 22:08:09 +0000
Subject: [PATCH 02/44] __main__: Set pyqtgraph colors from QPalette
---
Orange/canvas/__main__.py | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/Orange/canvas/__main__.py b/Orange/canvas/__main__.py
index d374c52e7da..9b23b78aad7 100644
--- a/Orange/canvas/__main__.py
+++ b/Orange/canvas/__main__.py
@@ -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")
From dee377b805e44d582202eb067b8f33d2dac2eaa1 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Wed, 14 Jul 2021 23:54:07 +0100
Subject: [PATCH 03/44] owpalette: Remove pyqtgraph colors set on global import
---
Orange/widgets/utils/plot/owpalette.py | 2 --
1 file changed, 2 deletions(-)
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)
From 774716eb98ed906aa3de8f725d833c964d4ac034 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Thu, 15 Jul 2021 01:02:53 +0100
Subject: [PATCH 04/44] pylint
---
Orange/canvas/__main__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Orange/canvas/__main__.py b/Orange/canvas/__main__.py
index 9b23b78aad7..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.")
From 111a4da9adb851b845f3dc4af41c89108d5959bb Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Sun, 16 Aug 2020 23:58:58 +0200
Subject: [PATCH 05/44] Port andreikop/Qutepart
4be1145e73964da1ba06f2570e3bf5d739603fb6
---
.../data/utils/pythoneditor/__init__.py | 0
.../data/utils/pythoneditor/bookmarks.py | 96 +
.../utils/pythoneditor/brackethlighter.py | 156 ++
.../data/utils/pythoneditor/completer.py | 491 +++++
.../widgets/data/utils/pythoneditor/editor.py | 1626 +++++++++++++++++
.../data/utils/pythoneditor/htmldelegate.py | 93 +
.../utils/pythoneditor/indenter/__init__.py | 242 +++
.../data/utils/pythoneditor/indenter/base.py | 297 +++
.../utils/pythoneditor/indenter/cstyle.py | 644 +++++++
.../data/utils/pythoneditor/indenter/lisp.py | 35 +
.../utils/pythoneditor/indenter/python.py | 107 ++
.../data/utils/pythoneditor/indenter/ruby.py | 297 +++
.../utils/pythoneditor/indenter/scheme.py | 79 +
.../utils/pythoneditor/indenter/xmlindent.py | 105 ++
.../widgets/data/utils/pythoneditor/lines.py | 187 ++
.../data/utils/pythoneditor/margins.py | 201 ++
.../pythoneditor/rectangularselection.py | 259 +++
.../data/utils/pythoneditor/sideareas.py | 186 ++
.../utils/pythoneditor/syntax/__init__.py | 269 +++
.../utils/pythoneditor/syntax/colortheme.py | 61 +
.../syntax/data/regenerate-definitions-db.py | 97 +
.../data/utils/pythoneditor/syntax/loader.py | 640 +++++++
.../data/utils/pythoneditor/syntax/parser.py | 1003 ++++++++++
.../data/utils/pythoneditor/syntaxhlighter.py | 304 +++
.../data/utils/pythoneditor/version.py | 10 +
Orange/widgets/data/utils/pythoneditor/vim.py | 1279 +++++++++++++
26 files changed, 8764 insertions(+)
create mode 100644 Orange/widgets/data/utils/pythoneditor/__init__.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/bookmarks.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/brackethlighter.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/completer.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/editor.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/htmldelegate.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/indenter/__init__.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/indenter/base.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/indenter/cstyle.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/indenter/lisp.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/indenter/python.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/indenter/ruby.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/indenter/scheme.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/indenter/xmlindent.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/lines.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/margins.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/rectangularselection.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/sideareas.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/syntax/__init__.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/syntax/colortheme.py
create mode 100755 Orange/widgets/data/utils/pythoneditor/syntax/data/regenerate-definitions-db.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/syntax/loader.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/syntax/parser.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/syntaxhlighter.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/version.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/vim.py
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/bookmarks.py b/Orange/widgets/data/utils/pythoneditor/bookmarks.py
new file mode 100644
index 00000000000..eaf3ecc2fc2
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/bookmarks.py
@@ -0,0 +1,96 @@
+"""
+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.
+"""
+"""Bookmarks functionality implementation"""
+
+from PyQt5.QtCore import Qt
+from PyQt5.QtWidgets import QAction
+from PyQt5.QtGui import QKeySequence, QTextCursor
+
+import qutepart
+
+
+class Bookmarks:
+ """Bookmarks functionality implementation, grouped in one class
+ """
+ def __init__(self, qpart, markArea):
+ self._qpart = qpart
+ self._markArea = markArea
+ qpart.toggleBookmarkAction = self._createAction(qpart, "emblem-favorite", "Toogle bookmark", 'Ctrl+B',
+ self._onToggleBookmark)
+ qpart.prevBookmarkAction = self._createAction(qpart, "go-up", "Previous bookmark", 'Alt+PgUp',
+ self._onPrevBookmark)
+ qpart.nextBookmarkAction = self._createAction(qpart, "go-down", "Next bookmark", 'Alt+PgDown',
+ self._onNextBookmark)
+
+ markArea.blockClicked.connect(self._toggleBookmark)
+
+ def _createAction(self, widget, iconFileName, text, shortcut, slot):
+ """Create QAction with given parameters and add to the widget
+ """
+ icon = qutepart.getIcon(iconFileName)
+ action = QAction(icon, text, widget)
+ action.setShortcut(QKeySequence(shortcut))
+ action.setShortcutContext(Qt.WidgetShortcut)
+ action.triggered.connect(slot)
+
+ widget.addAction(action)
+
+ return action
+
+ def removeActions(self):
+ self._qpart.removeAction(self._qpart.toggleBookmarkAction)
+ self._qpart.toggleBookmarkAction = None
+ self._qpart.removeAction(self._qpart.prevBookmarkAction)
+ self._qpart.prevBookmarkAction = None
+ self._qpart.removeAction(self._qpart.nextBookmarkAction)
+ self._qpart.nextBookmarkAction = None
+
+ def clear(self, startBlock, endBlock):
+ """Clear bookmarks on block range including start and end
+ """
+ for block in qutepart.iterateBlocksFrom(startBlock):
+ self._setBlockMarked(block, False)
+ if block == endBlock:
+ break
+
+ def isBlockMarked(self, block):
+ """Check if block is bookmarked
+ """
+ return self._markArea.isBlockMarked(block)
+
+ def _setBlockMarked(self, block, marked):
+ """Set block bookmarked
+ """
+ self._markArea.setBlockValue(block, 1 if marked else 0)
+
+ def _toggleBookmark(self, block):
+ self._markArea.toggleBlockMark(block)
+ self._markArea.update()
+
+ def _onToggleBookmark(self):
+ """Toogle Bookmark action triggered
+ """
+ self._toggleBookmark(self._qpart.textCursor().block())
+
+ def _onPrevBookmark(self):
+ """Previous Bookmark action triggered. Move cursor
+ """
+ for block in qutepart.iterateBlocksBackFrom(self._qpart.textCursor().block().previous()):
+ if self.isBlockMarked(block):
+ self._qpart.setTextCursor(QTextCursor(block))
+ return
+
+ def _onNextBookmark(self):
+ """Previous Bookmark action triggered. Move cursor
+ """
+ for block in qutepart.iterateBlocksFrom(self._qpart.textCursor().block().next()):
+ if self.isBlockMarked(block):
+ self._qpart.setTextCursor(QTextCursor(block))
+ return
diff --git a/Orange/widgets/data/utils/pythoneditor/brackethlighter.py b/Orange/widgets/data/utils/pythoneditor/brackethlighter.py
new file mode 100644
index 00000000000..6e9e1a1fd31
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/brackethlighter.py
@@ -0,0 +1,156 @@
+"""
+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.
+"""
+"""Bracket highlighter.
+Calculates list of QTextEdit.ExtraSelection
+"""
+
+import time
+
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QTextCursor
+from PyQt5.QtWidgets import QTextEdit
+
+
+class _TimeoutException(UserWarning):
+ """Operation timeout happened
+ """
+ pass
+
+
+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
+ """
+ _MAX_SEARCH_TIME_SEC = 0.02
+
+ _START_BRACKETS = '({['
+ _END_BRACKETS = ')}]'
+ _ALL_BRACKETS = _START_BRACKETS + _END_BRACKETS
+ _OPOSITE_BRACKET = dict( (bracket, oposite)
+ for (bracket, oposite) in zip(_START_BRACKETS + _END_BRACKETS, _END_BRACKETS + _START_BRACKETS))
+
+ currentMatchedBrackets = None # instance variable. None or ((block, columnIndex), (block, columnIndex))
+
+ 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 block, columnIndex, char in charsGenerator:
+ if qpart.isCode(block, columnIndex):
+ if char == oposite:
+ depth -= 1
+ if depth == 0:
+ return block, columnIndex
+ elif char == bracket:
+ depth += 1
+ else:
+ return None, None
+
+ def _makeMatchSelection(self, block, columnIndex, matched):
+ """Make matched or unmatched QTextEdit.ExtraSelection
+ """
+ selection = QTextEdit.ExtraSelection()
+
+ if matched:
+ bgColor = Qt.green
+ else:
+ bgColor = Qt.red
+
+ selection.format.setBackground(bgColor)
+ 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..2087b6f0f5c
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/completer.py
@@ -0,0 +1,491 @@
+"""
+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.
+"""
+"""Autocompletion widget and logic
+"""
+
+import re
+import time
+
+from PyQt5.QtCore import pyqtSignal, QAbstractItemModel, QEvent, QModelIndex, QObject, QSize, Qt, QTimer
+from PyQt5.QtWidgets import QListView
+from PyQt5.QtGui import QCursor
+
+from qutepart.htmldelegate import HTMLDelegate
+
+
+_wordPattern = "\w+"
+_wordRegExp = re.compile(_wordPattern)
+_wordAtEndRegExp = re.compile(_wordPattern + '$')
+_wordAtStartRegExp = re.compile('^' + _wordPattern)
+
+
+# Maximum count of words, for which completion will be shown. Ignored, if completion invoked manually.
+MAX_VISIBLE_WORD_COUNT = 256
+
+
+class _GlobalUpdateWordSetTimer:
+ """Timer updates word set, when editor is idle. (5 sec. after last change)
+ Timer is global, for avoid situation, when all instances
+ update set simultaneously
+ """
+ _IDLE_TIMEOUT_MS = 1000
+
+ def __init__(self):
+ self._timer = QTimer()
+ self._timer.setSingleShot(True)
+ self._timer.timeout.connect(self._onTimer)
+ self._scheduledMethods = []
+
+ def schedule(self, method):
+ if not method in self._scheduledMethods:
+ self._scheduledMethods.append(method)
+ self._timer.start(self._IDLE_TIMEOUT_MS)
+
+ def cancel(self, method):
+ """Cancel scheduled method
+ Safe method, may be called with not-scheduled method"""
+ if method in self._scheduledMethods:
+ self._scheduledMethods.remove(method)
+
+ if not self._scheduledMethods:
+ self._timer.stop()
+
+ def _onTimer(self):
+ method = self._scheduledMethods.pop()
+ method()
+ if self._scheduledMethods:
+ self._timer.start(self._IDLE_TIMEOUT_MS)
+
+
+class _CompletionModel(QAbstractItemModel):
+ """QAbstractItemModel implementation for a list of completion variants
+
+ words attribute contains all words
+ canCompleteText attribute contains text, which may be inserted with tab
+ """
+ def __init__(self, wordSet):
+ QAbstractItemModel.__init__(self)
+
+ self._wordSet = wordSet
+
+ def setData(self, wordBeforeCursor, wholeWord):
+ """Set model information
+ """
+ self._typedText = wordBeforeCursor
+ self.words = self._makeListOfCompletions(wordBeforeCursor, wholeWord)
+ commonStart = self._commonWordStart(self.words)
+ self.canCompleteText = commonStart[len(wordBeforeCursor):]
+
+ self.layoutChanged.emit()
+
+ def hasWords(self):
+ return len(self.words) > 0
+
+ def tooManyWords(self):
+ return len(self.words) > MAX_VISIBLE_WORD_COUNT
+
+ def data(self, index, role):
+ """QAbstractItemModel method implementation
+ """
+ if role == Qt.DisplayRole and \
+ index.row() < len(self.words):
+ text = self.words[index.row()]
+ typed = text[:len(self._typedText)]
+ canComplete = text[len(self._typedText):len(self._typedText) + len(self.canCompleteText)]
+ rest = text[len(self._typedText) + len(self.canCompleteText):]
+ if canComplete:
+ # NOTE foreground colors are hardcoded, but I can't set background color of selected item (Qt bug?)
+ # might look bad on some color themes
+ return '' \
+ '%s' \
+ '%s' \
+ '%s' \
+ '' % (typed, canComplete, rest)
+ else:
+ return typed + rest
+ else:
+ return None
+
+ def rowCount(self, index = QModelIndex()):
+ """QAbstractItemModel method implementation
+ """
+ return len(self.words)
+
+ def typedText(self):
+ """Get current typed text
+ """
+ return self._typedText
+
+ def _commonWordStart(self, words):
+ """Get common start of all words.
+ i.e. for ['blablaxxx', 'blablayyy', 'blazzz'] common start is 'bla'
+ """
+ if not words:
+ return ''
+
+ length = 0
+ firstWord = words[0]
+ otherWords = words[1:]
+ for index, char in enumerate(firstWord):
+ if not all([word[index] == char for word in otherWords]):
+ break
+ length = index + 1
+
+ return firstWord[:length]
+
+ def _makeListOfCompletions(self, wordBeforeCursor, wholeWord):
+ """Make list of completions, which shall be shown
+ """
+ onlySuitable = [word for word in self._wordSet \
+ if word.startswith(wordBeforeCursor) and \
+ word != wholeWord]
+
+ return sorted(onlySuitable)
+
+ """Trivial QAbstractItemModel methods implementation
+ """
+ def flags(self, index): return Qt.ItemIsEnabled | Qt.ItemIsSelectable
+ def headerData(self, index): return None
+ def columnCount(self, index): return 1
+ def index(self, row, column, parent = QModelIndex()): return self.createIndex(row, column)
+ def parent(self, index): return QModelIndex()
+
+
+class _CompletionList(QListView):
+ """Completion list widget
+ """
+ closeMe = pyqtSignal()
+ itemSelected = pyqtSignal(int)
+ tabPressed = pyqtSignal()
+
+ _MAX_VISIBLE_ROWS = 20 # no any technical reason, just for better UI
+
+ def __init__(self, qpart, model):
+ QListView.__init__(self, qpart.viewport())
+
+ # ensure good selected item background on Windows
+ palette = self.palette()
+ palette.setColor(palette.Inactive, palette.Highlight, palette.color(palette.Active, palette.Highlight))
+ self.setPalette(palette)
+
+ self.setAttribute(Qt.WA_DeleteOnClose)
+
+ self.setItemDelegate(HTMLDelegate(self))
+
+ self._qpart = qpart
+ self.setFont(qpart.font())
+
+ self.setCursor(QCursor(Qt.PointingHandCursor))
+ self.setFocusPolicy(Qt.NoFocus)
+
+ self.setModel(model)
+
+ self._selectedIndex = -1
+
+ # if cursor moved, we shall close widget, if its position (and model) hasn't been updated
+ self._closeIfNotUpdatedTimer = QTimer(self)
+ self._closeIfNotUpdatedTimer.setInterval(200)
+ self._closeIfNotUpdatedTimer.setSingleShot(True)
+
+ self._closeIfNotUpdatedTimer.timeout.connect(self._afterCursorPositionChanged)
+
+ qpart.installEventFilter(self)
+
+ qpart.cursorPositionChanged.connect(self._onCursorPositionChanged)
+
+ self.clicked.connect(lambda index: self.itemSelected.emit(index.row()))
+
+ self.updateGeometry()
+ self.show()
+
+ qpart.setFocus()
+
+ def __del__(self):
+ """Without this empty destructor Qt prints strange trace
+ QObject::startTimer: QTimer can only be used with threads started with QThread
+ when exiting
+ """
+ pass
+
+ def close(self):
+ """Explicitly called destructor.
+ Removes widget from the qpart
+ """
+ self._closeIfNotUpdatedTimer.stop()
+ self._qpart.removeEventFilter(self)
+ self._qpart.cursorPositionChanged.disconnect(self._onCursorPositionChanged)
+
+ QListView.close(self)
+
+ def sizeHint(self):
+ """QWidget.sizeHint implementation
+ Automatically resizes the widget according to rows count
+
+ FIXME very bad algorithm. Remove all this margins, if you can
+ """
+ width = max([self.fontMetrics().width(word) \
+ for word in self.model().words])
+ width = width * 1.4 # FIXME bad hack. invent better formula
+ width += 30 # margin
+
+ # drawn with scrollbar without +2. I don't know why
+ rowCount = min(self.model().rowCount(), self._MAX_VISIBLE_ROWS)
+ height = self.sizeHintForRow(0) * (rowCount + 0.5) # + 0.5 row margin
+
+ return QSize(width, height)
+
+ def minimumHeight(self):
+ """QWidget.minimumSizeHint implementation
+ """
+ return self.sizeHintForRow(0) * 1.5 # + 0.5 row margin
+
+ def _horizontalShift(self):
+ """List should be plased such way, that typed text in the list is under
+ typed text in the editor
+ """
+ strangeAdjustment = 2 # I don't know why. Probably, won't work on other systems and versions
+ return self.fontMetrics().width(self.model().typedText()) + strangeAdjustment
+
+ def updateGeometry(self):
+ """Move widget to point under cursor
+ """
+ WIDGET_BORDER_MARGIN = 5
+ SCROLLBAR_WIDTH = 30 # just a guess
+
+ sizeHint = self.sizeHint()
+ width = sizeHint.width()
+ height = sizeHint.height()
+
+ cursorRect = self._qpart.cursorRect()
+ parentSize = self.parentWidget().size()
+
+ spaceBelow = parentSize.height() - cursorRect.bottom() - WIDGET_BORDER_MARGIN
+ spaceAbove = cursorRect.top() - WIDGET_BORDER_MARGIN
+
+ if height <= spaceBelow or \
+ spaceBelow > spaceAbove:
+ yPos = cursorRect.bottom()
+ if height > spaceBelow and \
+ spaceBelow > self.minimumHeight():
+ height = spaceBelow
+ width = width + SCROLLBAR_WIDTH
+ else:
+ if height > spaceAbove and \
+ spaceAbove > self.minimumHeight():
+ height = spaceAbove
+ width = width + SCROLLBAR_WIDTH
+ yPos = max(3, cursorRect.top() - height)
+
+ xPos = cursorRect.right() - self._horizontalShift()
+
+ if xPos + width + WIDGET_BORDER_MARGIN > parentSize.width():
+ xPos = max(3, parentSize.width() - WIDGET_BORDER_MARGIN - width)
+
+ self.setGeometry(xPos, yPos, width, height)
+ self._closeIfNotUpdatedTimer.stop()
+
+ def _onCursorPositionChanged(self):
+ """Cursor position changed. Schedule closing.
+ Timer will be stopped, if widget position is being updated
+ """
+ self._closeIfNotUpdatedTimer.start()
+
+ def _afterCursorPositionChanged(self):
+ """Widget position hasn't been updated after cursor position change, close widget
+ """
+ self.closeMe.emit()
+
+ def eventFilter(self, object, event):
+ """Catch events from qpart
+ Move selection, select item, or close themselves
+ """
+ if event.type() == QEvent.KeyPress and event.modifiers() == Qt.NoModifier:
+ if event.key() == Qt.Key_Escape:
+ self.closeMe.emit()
+ return True
+ elif event.key() == Qt.Key_Down:
+ if self._selectedIndex + 1 < self.model().rowCount():
+ self._selectItem(self._selectedIndex + 1)
+ return True
+ elif event.key() == Qt.Key_Up:
+ if self._selectedIndex - 1 >= 0:
+ self._selectItem(self._selectedIndex - 1)
+ return True
+ elif event.key() in (Qt.Key_Enter, Qt.Key_Return):
+ if self._selectedIndex != -1:
+ self.itemSelected.emit(self._selectedIndex)
+ return True
+ elif event.key() == Qt.Key_Tab:
+ self.tabPressed.emit()
+ return True
+ elif event.type() == QEvent.FocusOut:
+ self.closeMe.emit()
+
+ return False
+
+ def _selectItem(self, index):
+ """Select item in the list
+ """
+ self._selectedIndex = index
+ self.setCurrentIndex(self.model().createIndex(index, 0))
+
+
+class Completer(QObject):
+ """Object listens Qutepart widget events, computes and shows autocompletion lists
+ """
+ _globalUpdateWordSetTimer = _GlobalUpdateWordSetTimer()
+
+ _WORD_SET_UPDATE_MAX_TIME_SEC = 0.4
+
+ def __init__(self, qpart):
+ QObject.__init__(self, qpart)
+
+ self._qpart = qpart
+ self._widget = None
+ self._completionOpenedManually = False
+
+ self._keywords = set()
+ self._customCompletions = set()
+ self._wordSet = None
+
+ qpart.textChanged.connect(self._onTextChanged)
+ qpart.document().modificationChanged.connect(self._onModificationChanged)
+
+ def terminate(self):
+ """Object deleted. Cancel timer
+ """
+ self._globalUpdateWordSetTimer.cancel(self._updateWordSet)
+
+ def setKeywords(self, keywords):
+ self._keywords = keywords
+ self._updateWordSet()
+
+ def setCustomCompletions(self, wordSet):
+ self._customCompletions = wordSet
+
+ def isVisible(self):
+ return self._widget is not None
+
+ def _onTextChanged(self):
+ """Text in the qpart changed. Update word set"""
+ self._globalUpdateWordSetTimer.schedule(self._updateWordSet)
+
+ def _onModificationChanged(self, modified):
+ if not modified:
+ self._closeCompletion()
+
+ def _updateWordSet(self):
+ """Make a set of words, which shall be completed, from text
+ """
+ self._wordSet = set(self._keywords) | set(self._customCompletions)
+
+ start = time.time()
+
+ for line in self._qpart.lines:
+ for match in _wordRegExp.findall(line):
+ self._wordSet.add(match)
+ if time.time() - start > self._WORD_SET_UPDATE_MAX_TIME_SEC:
+ """It is better to have incomplete word set, than to freeze the GUI"""
+ break
+
+ def invokeCompletion(self):
+ """Invoke completion manually"""
+ if self.invokeCompletionIfAvailable(requestedByUser=True):
+ self._completionOpenedManually = True
+
+
+ def _shouldShowModel(self, model, forceShow):
+ if not model.hasWords():
+ return False
+
+ return forceShow or \
+ (not model.tooManyWords())
+
+ def _createWidget(self, model):
+ self._widget = _CompletionList(self._qpart, model)
+ self._widget.closeMe.connect(self._closeCompletion)
+ self._widget.itemSelected.connect(self._onCompletionListItemSelected)
+ self._widget.tabPressed.connect(self._onCompletionListTabPressed)
+
+ def invokeCompletionIfAvailable(self, requestedByUser=False):
+ """Invoke completion, if available. Called after text has been typed in qpart
+ Returns True, if invoked
+ """
+ if self._qpart.completionEnabled and self._wordSet is not None:
+ wordBeforeCursor = self._wordBeforeCursor()
+ wholeWord = wordBeforeCursor + self._wordAfterCursor()
+
+ forceShow = requestedByUser or self._completionOpenedManually
+ if wordBeforeCursor:
+ if len(wordBeforeCursor) >= self._qpart.completionThreshold or forceShow:
+ if self._widget is None:
+ model = _CompletionModel(self._wordSet)
+ model.setData(wordBeforeCursor, wholeWord)
+ if self._shouldShowModel(model, forceShow):
+ self._createWidget(model)
+ return True
+ else:
+ self._widget.model().setData(wordBeforeCursor, wholeWord)
+ if self._shouldShowModel(self._widget.model(), forceShow):
+ self._widget.updateGeometry()
+
+ return True
+
+ self._closeCompletion()
+ return False
+
+ def _closeCompletion(self):
+ """Close completion, if visible.
+ Delete widget
+ """
+ if self._widget is not None:
+ self._widget.close()
+ self._widget = None
+ self._completionOpenedManually = False
+
+ def _wordBeforeCursor(self):
+ """Get word, which is located before cursor
+ """
+ cursor = self._qpart.textCursor()
+ textBeforeCursor = cursor.block().text()[:cursor.positionInBlock()]
+ match = _wordAtEndRegExp.search(textBeforeCursor)
+ if match:
+ return match.group(0)
+ else:
+ return ''
+
+ def _wordAfterCursor(self):
+ """Get word, which is located before cursor
+ """
+ cursor = self._qpart.textCursor()
+ textAfterCursor = cursor.block().text()[cursor.positionInBlock():]
+ match = _wordAtStartRegExp.search(textAfterCursor)
+ if match:
+ return match.group(0)
+ else:
+ return ''
+
+ def _onCompletionListItemSelected(self, index):
+ """Item selected. Insert completion to editor
+ """
+ model = self._widget.model()
+ selectedWord = model.words[index]
+ textToInsert = selectedWord[len(model.typedText()):]
+ self._qpart.textCursor().insertText(textToInsert)
+ self._closeCompletion()
+
+ def _onCompletionListTabPressed(self):
+ """Tab pressed on completion list
+ Insert completable text, if available
+ """
+ canCompleteText = self._widget.model().canCompleteText
+ if canCompleteText:
+ self._qpart.textCursor().insertText(canCompleteText)
+ self.invokeCompletionIfAvailable()
diff --git a/Orange/widgets/data/utils/pythoneditor/editor.py b/Orange/widgets/data/utils/pythoneditor/editor.py
new file mode 100644
index 00000000000..9db5e7e874f
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/editor.py
@@ -0,0 +1,1626 @@
+"""
+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.
+"""
+"""qutepart --- Code editor component for PyQt and Pyside
+=========================================================
+"""
+
+import sys
+import os.path
+import logging
+import platform
+
+from PyQt5.QtCore import QRect, Qt, pyqtSignal
+from PyQt5.QtWidgets import QAction, QApplication, QDialog, QPlainTextEdit, QTextEdit, QWidget
+from PyQt5.QtPrintSupport import QPrintDialog
+from PyQt5.QtGui import QColor, QBrush, \
+ QFont, \
+ QIcon, QKeySequence, QPainter, QPen, QPalette, \
+ QTextCharFormat, QTextCursor, \
+ QTextBlock, QTextFormat
+
+from qutepart.syntax import SyntaxManager
+import qutepart.version
+
+
+if 'sphinx-build' not in sys.argv[0]:
+ # See explanation near `import sip` above
+ from qutepart.syntaxhlighter import SyntaxHighlighter
+ from qutepart.brackethlighter import BracketHighlighter
+ from qutepart.completer import Completer
+ from qutepart.lines import Lines
+ from qutepart.rectangularselection import RectangularSelection
+ import qutepart.sideareas
+ from qutepart.indenter import Indenter
+ import qutepart.vim
+
+ def setPositionInBlock(cursor, positionInBlock, anchor=QTextCursor.MoveAnchor):
+ return cursor.setPosition(cursor.block().position() + positionInBlock, anchor)
+
+
+VERSION = qutepart.version.VERSION
+
+
+logger = logging.getLogger('qutepart')
+consoleHandler = logging.StreamHandler()
+consoleHandler.setFormatter(logging.Formatter("qutepart: %(message)s"))
+logger.addHandler(consoleHandler)
+
+logger.setLevel(logging.ERROR)
+
+
+# After logging setup
+import qutepart.syntax.loader
+binaryParserAvailable = qutepart.syntax.loader.binaryParserAvailable
+
+
+_ICONS_PATH = os.path.join(os.path.dirname(__file__), 'icons')
+
+def getIcon(iconFileName):
+ icon = QIcon.fromTheme(iconFileName)
+ if icon.name() != iconFileName:
+ # Use bundled fallback icon
+ icon = QIcon(os.path.join(_ICONS_PATH, iconFileName))
+ return icon
+
+
+#Define for old Qt versions methods, which appeared in 4.7
+if not hasattr(QTextCursor, 'positionInBlock'):
+ def _positionInBlock(cursor):
+ return cursor.position() - cursor.block().position()
+ QTextCursor.positionInBlock = _positionInBlock
+
+
+
+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 Qutepart(QPlainTextEdit):
+ '''Qutepart is based on QPlainTextEdit, and you can use QPlainTextEdit methods,
+ if you don't see some functionality here.
+
+ **Text**
+
+ ``text`` attribute holds current text. It may be read and written.::
+
+ qpart.text = readFile()
+ saveFile(qpart.text)
+
+ This attribute always returns text, separated with ``\\n``. Use ``textForSaving()`` for get original text.
+
+ It is recommended to use ``lines`` attribute whenever possible,
+ because access to ``text`` might require long time on big files.
+ Attribute is cached, only first read access after text has been changed in slow.
+
+ **Selected text**
+
+ ``selectedText`` attribute holds selected text. It may be read and written.
+ Write operation replaces selection with new text. If nothing is selected - just inserts text::
+
+ print qpart.selectedText # print selection
+ qpart.selectedText = 'new text' # replace selection
+
+ **Text lines**
+
+ ``lines`` attribute, which represents text as list-of-strings like object
+ and allows to modify it. Examples::
+
+ qpart.lines[0] # get the first line of the text
+ qpart.lines[-1] # get the last line of the text
+ qpart.lines[2] = 'new text' # replace 3rd line value with 'new text'
+ qpart.lines[1:4] # get 3 lines of text starting from the second line as list of strings
+ qpart.lines[1:4] = ['new line 2', 'new line3', 'new line 4'] # replace value of 3 lines
+ del qpart.lines[3] # delete 4th line
+ del qpart.lines[3:5] # delete lines 4, 5, 6
+
+ len(qpart.lines) # get line count
+
+ qpart.lines.append('new line') # append new line to the end
+ qpart.lines.insert(1, 'new line') # insert new line before line 1
+
+ print qpart.lines # print all text as list of strings
+
+ # iterate over lines.
+ for lineText in qpart.lines:
+ doSomething(lineText)
+
+ qpart.lines = ['one', 'thow', 'three'] # replace whole text
+
+ **Position and selection**
+
+ * ``cursorPosition`` - cursor position as ``(line, column)``. Lines are numerated from zero. If column is set to ``None`` - cursor will be placed before first non-whitespace character. If line or column is bigger, than actual file, cursor will be placed to the last line, to the last column
+ * ``absCursorPosition`` - cursor position as offset from the beginning of text.
+ * ``selectedPosition`` - selection coordinates as ``((startLine, startCol), (cursorLine, cursorCol))``.
+ * ``absSelectedPosition`` - selection coordinates as ``(startPosition, cursorPosition)`` where position is offset from the beginning of text.
+ Rectangular selection is not available via API currently.
+
+ **EOL, indentation, edge, current line**
+
+ * ``eol`` - End Of Line character. Supported values are ``\\n``, ``\\r``, ``\\r\\n``. See comments for ``textForSaving()``
+ * ``indentWidth`` - Width of ``Tab`` character, and width of one indentation level. Default is ``4``.
+ * ``indentUseTabs`` - If True, ``Tab`` character inserts ``\\t``, otherwise - spaces. Default is ``False``.
+ * ``lineLengthEdge`` - If not ``None`` - maximal allowed line width (i.e. 80 chars). Longer lines are marked with red (see ``lineLengthEdgeColor``) line. Default is ``None``.
+ * ``lineLengthEdgeColor`` - Color of line length edge line. Default is red.
+ * ``drawSolidEdge`` - Draw the edge as a solid vertical line. Default is ``False``.
+ * ``drawIndentations`` - Draw indentations. Default is ``True``.
+ * ``currentLineColor`` - Color of the current line background. If None then the current line is not highlighted. Default: #ffffa3
+
+ **Visible white spaces**
+
+ * ``drawIncorrectIndentation`` - Draw trailing whitespaces, tabs if text is indented with spaces, spaces if text is indented with tabs. Default is ``True``. Doesn't have any effect if ``drawAnyWhitespace`` is ``True``.
+ * ``drawAnyWhitespace`` - Draw trailing and other whitespaces, used as indentation. Default is ``False``.
+
+ **Autocompletion**
+
+ Qutepart supports autocompletion, based on document contents.
+ It is enabled, if ``completionEnabled`` is ``True``.
+ ``completionThreshold`` is count of typed symbols, after which completion is shown.
+
+ **Linters support**
+
+ * ``lintMarks`` Linter messages as {lineNumber: (type, text)} dictionary. Cleared on any edit operation. Type is one of `Qutepart.LINT_ERROR, Qutepart.LINT_WARNING, Qutepart.LINT_NOTE)
+
+ **Vim mode**
+
+ ``vimModeEnabled`` - read-write property switches Vim mode. See also ``vimModeEnabledChanged``.
+ ``vimModeIndication`` - An application shall display a label, which shows current Vim mode. This read-only property contains (QColor, str) to be displayed on the label. See also ``vimModeIndicationChanged``.
+
+ **Actions**
+
+ Component contains list of actions (QAction instances).
+ Actions can be insered to some menu, a shortcut and an icon can be configured.
+
+ Bookmarks:
+
+ * ``toggleBookmarkAction`` - Set/Clear bookmark on current block
+ * ``nextBookmarkAction`` - Jump to next bookmark
+ * ``prevBookmarkAction`` - Jump to previous bookmark
+
+ Scroll:
+
+ * ``scrollUpAction`` - Scroll viewport Up
+ * ``scrollDownAction`` - Scroll viewport Down
+ * ``selectAndScrollUpAction`` - Select 1 line Up and scroll
+ * ``selectAndScrollDownAction`` - Select 1 line Down and scroll
+
+ Indentation:
+
+ * ``increaseIndentAction`` - Increase indentation by 1 level
+ * ``decreaseIndentAction`` - Decrease indentation by 1 level
+ * ``autoIndentLineAction`` - Autoindent line
+ * ``indentWithSpaceAction`` - Indent all selected lines by 1 space symbol
+ * ``unIndentWithSpaceAction`` - Unindent all selected lines by 1 space symbol
+
+ Lines:
+
+ * ``moveLineUpAction`` - Move line Up
+ * ``moveLineDownAction`` - Move line Down
+ * ``deleteLineAction`` - Delete line
+ * ``copyLineAction`` - Copy line
+ * ``pasteLineAction`` - Paste line
+ * ``cutLineAction`` - Cut line
+ * ``duplicateLineAction`` - Duplicate line
+
+ Other:
+ * ``undoAction`` - Undo
+ * ``redoAction`` - Redo
+ * ``invokeCompletionAction`` - Invoke completion
+ * ``printAction`` - Print file
+
+ **Text modification and Undo/Redo**
+
+ Sometimes, it is required to make few text modifications, which are Undo-Redoble as atomic operation.
+ i.e. you want to indent (insert indentation) few lines of text, but user shall be able to
+ Undo it in one step. In this case, you can use Qutepart as a context manager.::
+
+ with qpart:
+ qpart.modifySomeText()
+ qpart.modifyOtherText()
+
+ Nested atomic operations are joined in one operation
+
+ **Signals**
+
+ * ``userWarning(text)``` Warning, which shall be shown to the user on status bar. I.e. 'Rectangular selection area is too big'
+ * ``languageChanged(langName)``` Language has changed. See also ``language()``
+ * ``indentWidthChanged(int)`` Indentation width changed. See also ``indentWidth``
+ * ``indentUseTabsChanged(bool)`` Indentation uses tab property changed. See also ``indentUseTabs``
+ * ``eolChanged(eol)`` EOL mode changed. See also ``eol``.
+ * ``vimModeEnabledChanged(enabled) Vim mode has been enabled or disabled.
+ * ``vimModeIndicationChanged(color, text)`` Vim mode changed. Parameters contain color and text to be displayed on an indicator. See also ``vimModeIndication``
+
+ **Syntax parser**
+
+ Qutepart supports two syntax parsers. One of them is written in C (faster) and the
+ other in Python (slower). By default qutepart tries to load the faster parser and
+ falls back to the slower one if there are import errors.
+ If by some reasons a slower Python parser is preferred then qutepart can be
+ instructed not to try to import the C parser. In order to do so an environment
+ variable can be used (it needs to be set before the first import of qutepart), e.g.::
+
+ import os
+ os.environ['QPART_CPARSER'] = 'N' # Python written syntax parser to be used
+
+ import qutepart
+
+ **Public methods**
+ '''
+
+ userWarning = pyqtSignal(str)
+ languageChanged = pyqtSignal(str)
+ indentWidthChanged = pyqtSignal(int)
+ indentUseTabsChanged = pyqtSignal(bool)
+ eolChanged = pyqtSignal(str)
+ vimModeIndicationChanged = pyqtSignal(QColor, str)
+ vimModeEnabledChanged = pyqtSignal(bool)
+
+ LINT_ERROR = 'e'
+ LINT_WARNING = 'w'
+ LINT_NOTE = 'n'
+
+ _DEFAULT_EOL = '\n'
+
+ _DEFAULT_COMPLETION_THRESHOLD = 3
+ _DEFAULT_COMPLETION_ENABLED = True
+
+ _globalSyntaxManager = SyntaxManager()
+
+ def __init__(self,
+ needMarkArea=True,
+ needLineNumbers=True,
+ needCompleter=True,
+ *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._currentLineColor = QColor('#ffffa3')
+ 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()
+ palette.setColor(QPalette.Base, QColor('#ffffff'))
+ palette.setColor(QPalette.Text, QColor('#000000'))
+ self.setPalette(palette)
+
+ self._highlighter = None
+ self._bracketHighlighter = BracketHighlighter()
+
+ self._lines = Lines(self)
+
+ self.completionThreshold = self._DEFAULT_COMPLETION_THRESHOLD
+ self.completionEnabled = self._DEFAULT_COMPLETION_ENABLED
+ self._completer = None
+ if needCompleter:
+ self._completer = Completer(self)
+
+ self._vim = None
+
+ self._initActions()
+
+ self._margins = []
+ self._totalMarginWidth = -1
+
+ if needLineNumbers:
+ self.addMargin(qutepart.sideareas.LineNumberArea(self))
+ if needMarkArea:
+ self.addMargin(qutepart.sideareas.MarkArea(self))
+
+ self._nonVimExtraSelections = []
+ self._userExtraSelections = [] # we draw bracket highlighting, current line and extra selections by user
+ 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)
+
+ fontFamilies = {'Windows':'Courier New',
+ 'Darwin': 'Menlo'}
+ fontFamily = fontFamilies.get(platform.system(), 'Monospace')
+ self.setFont(QFont(fontFamily))
+
+ self._updateExtraSelections()
+
+ 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._highlighter is not None:
+ self._highlighter.terminate()
+
+ if self._vim is not None:
+ self._vim.terminate()
+
+ 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
+
+ # 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)
+ self.invokeCompletionAction = createAction('Invoke completion', 'Ctrl+Space', self._onCompletion)
+
+ # other
+ self.printAction = createAction('Print', 'Ctrl+P', self._onShortcutPrint, 'document-print')
+
+ 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()
+
+ if exc_type is not None:
+ return False
+
+ def setFont(self, font):
+ pass # suppress dockstring for non-public method
+ """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.getMargin("line_numbers")
+ if lineNumbersMargin:
+ lineNumbersMargin.setFont(font)
+
+ def showEvent(self, ev):
+ pass # suppress dockstring for non-public method
+ """ 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().width(' ' * 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
+
+ @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 vimModeEnabled(self):
+ return self._vim is not None
+
+ @vimModeEnabled.setter
+ def vimModeEnabled(self, enabled):
+ if enabled:
+ if self._vim is None:
+ self._vim = qutepart.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 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 detectSyntax(self,
+ xmlFileName=None,
+ mimeType=None,
+ language=None,
+ sourceFilePath=None,
+ firstLine=None):
+ """Get syntax by next parameters (fill as many, as known):
+
+ * name of XML file with syntax definition
+ * MIME type of source file
+ * Programming language name
+ * Source file path
+ * First line of source file
+
+ First parameter in the list has the hightest priority.
+ Old syntax is always cleared, even if failed to detect new.
+
+ Method returns ``True``, if syntax is detected, and ``False`` otherwise
+ """
+ oldLanguage = self.language()
+
+ self.clearSyntax()
+
+ syntax = self._globalSyntaxManager.getSyntax(xmlFileName=xmlFileName,
+ mimeType=mimeType,
+ languageName=language,
+ sourceFilePath=sourceFilePath,
+ firstLine=firstLine)
+
+ if syntax is not None:
+ self._highlighter = SyntaxHighlighter(syntax, self)
+ self._indenter.setSyntax(syntax)
+ if self._completer:
+ keywords = {kw for kwList in syntax.parser.lists.values() for kw in kwList}
+ self._completer.setKeywords(keywords)
+
+ newLanguage = self.language()
+ if oldLanguage != newLanguage:
+ self.languageChanged.emit(newLanguage)
+
+ return syntax is not None
+
+ def clearSyntax(self):
+ """Clear syntax. Disables syntax highlighting
+
+ This method might take long time, if document is big. Don't call it if you don't have to (i.e. in destructor)
+ """
+ if self._highlighter is not None:
+ self._highlighter.terminate()
+ self._highlighter = None
+ self.languageChanged.emit(None)
+
+ def language(self):
+ """Get current language name.
+ Return ``None`` for plain text
+ """
+ if self._highlighter is None:
+ return None
+ else:
+ return self._highlighter.syntax().name
+
+ def setCustomCompletions(self, wordSet):
+ """Add a set of custom completions to the editors completions.
+
+ This set is managed independently of the set of keywords and words from
+ the current document, and can thus be changed at any time.
+
+ """
+ if not isinstance(wordSet, set):
+ raise TypeError('"wordSet" is not a set: %s' % type(wordSet))
+ if self._completer:
+ self._completer.setCustomCompletions(wordSet)
+
+ def isHighlightingInProgress(self):
+ """Check if text highlighting is still in progress
+ """
+ return self._highlighter is not None and \
+ self._highlighter.isInProgress()
+
+ 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)
+
+ return self._highlighter is None or \
+ self._highlighter.isCode(block, column)
+
+ def isComment(self, line, column):
+ """Check if text at given position is a comment. Including block comments and here documents.
+
+ If language is not known, or text is not parsed yet, ``False`` is returned
+ """
+ return self._highlighter is not None and \
+ self._highlighter.isComment(self.document().findBlockByNumber(line), column)
+
+ def isBlockComment(self, line, column):
+ """Check if text at given position is a block comment.
+
+ If language is not known, or text is not parsed yet, ``False`` is returned
+ """
+ return self._highlighter is not None and \
+ self._highlighter.isBlockComment(self.document().findBlockByNumber(line), column)
+
+ def isHereDoc(self, line, column):
+ """Check if text at given position is a here document.
+
+ If language is not known, or text is not parsed yet, ``False`` is returned
+ """
+ return self._highlighter is not None and \
+ self._highlighter.isHereDoc(self.document().findBlockByNumber(line), column)
+
+ 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 updateViewport(self):
+ pass # suppress docstring for non-public method
+ """Recalculates geometry for all the margins and the editor viewport
+ """
+ cr = self.contentsRect()
+ currentX = cr.left()
+ top = cr.top()
+ height = cr.height()
+
+ totalMarginWidth = 0
+ for margin in self._margins:
+ if not margin.isHidden():
+ width = margin.width()
+ margin.setGeometry(QRect(currentX, top, width, height))
+ currentX += width
+ totalMarginWidth += width
+
+ if self._totalMarginWidth != totalMarginWidth:
+ self._totalMarginWidth = totalMarginWidth
+ self.updateViewportMargins()
+ else:
+ self._setSolidEdgeGeometry()
+
+ def updateViewportMargins(self):
+ pass # suppress docstring for non-public method
+ """Sets the viewport margins and the solid edge geometry
+ """
+ self.setViewportMargins(self._totalMarginWidth, 0, 0, 0)
+ self._setSolidEdgeGeometry()
+
+ def resizeEvent(self, event):
+ pass # suppress docstring for non-public method
+ """QWidget.resizeEvent() implementation.
+ Adjust line number area
+ """
+ QPlainTextEdit.resizeEvent(self, event)
+ self.updateViewport()
+ return
+
+ 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._totalMarginWidth + \
+ self.contentsMargins().left() + \
+ self.__cursorRect(self.firstVisibleBlock(), 0, offset=0).left()
+ self._solidEdgeLine.setGeometry(QRect(x, cr.top(), 1, cr.bottom()))
+
+ 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 textBeforeCursor(self):
+ pass # suppress docstring for non-API method, used by internal classes
+ """Text in current block from start to cursor position
+ """
+ cursor = self.textCursor()
+ return cursor.block().text()[:cursor.positionInBlock()]
+
+ def keyPressEvent(self, event):
+ pass # suppress dockstring for non-public method
+ """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)
+
+ if 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 \
+ qutepart.vim.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(Qutepart, self).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:
+ if event.text() and event.modifiers() == Qt.AltModifier:
+ return # alt+letter is a shortcut. Not mine
+ else:
+ self._lastKeyPressProcessedByParent = True
+ super(Qutepart, self).keyPressEvent(event)
+
+ 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 == '_')
+
+ if textTyped or \
+ (event.key() == Qt.Key_Backspace and self._completer.isVisible()):
+ self._completer.invokeCompletionIfAvailable()
+
+ super(Qutepart, self).keyReleaseEvent(event)
+
+ def mousePressEvent(self, mouseEvent):
+ pass # suppress docstring for non-public method
+ if mouseEvent.modifiers() in RectangularSelection.MOUSE_MODIFIERS and \
+ mouseEvent.button() == Qt.LeftButton:
+ self._rectangularSelection.mousePressEvent(mouseEvent)
+ else:
+ super(Qutepart, self).mousePressEvent(mouseEvent)
+
+ def mouseMoveEvent(self, mouseEvent):
+ pass # suppress docstring for non-public method
+ if mouseEvent.modifiers() in RectangularSelection.MOUSE_MODIFIERS and \
+ mouseEvent.buttons() == Qt.LeftButton:
+ self._rectangularSelection.mouseMoveEvent(mouseEvent)
+ else:
+ super(Qutepart, self).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
+ else: # 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.blue).lighter())
+ rect = self.__cursorRect(block, column, offset=0)
+ painter.drawLine(rect.topLeft(), rect.bottomLeft())
+
+ 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
+ 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
+
+ # Draw edge, but not over a cursor
+ if not self._drawSolidEdge:
+ edgePos = effectiveEdgePos(block.text())
+ if edgePos != -1 and edgePos != 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):
+ pass # suppress dockstring for non-public method
+ """Paint event
+ Draw indentation markers after main contents is drawn
+ """
+ super(Qutepart, self).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 _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 _onShortcutIndent(self):
+ if self.textCursor().hasSelection():
+ self._indenter.onChangeSelectedBlocksIndent(increase=True)
+ 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
+
+ # if operaiton is UnDone, marks are located incorrectly
+ markMargin = self.getMargin("mark_area")
+ if markMargin:
+ markMargin.clearBookmarks(startBlock, endBlock.next())
+
+ _moveBlock(blockToMove, startBlockNumber)
+
+ self._selectLines(startBlockNumber + 1, endBlockNumber + 1)
+ else: # move previous block down
+ blockToMove = startBlock.previous()
+ if not blockToMove.isValid():
+ return
+
+ # if operaiton is UnDone, marks are located incorrectly
+ markMargin = self.getMargin("mark_area")
+ if markMargin:
+ markMargin.clearBookmarks(startBlock, endBlock)
+
+ _moveBlock(blockToMove, endBlockNumber)
+
+ self._selectLines(startBlockNumber - 1, endBlockNumber - 1)
+
+ if markMargin:
+ markMargin.update()
+
+ 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
+ """
+ lines = self.lines[self._selectedLinesSlice()]
+ text = QApplication.clipboard().text()
+ if text:
+ with self:
+ if self.textCursor().hasSelection():
+ startBlockNumber, endBlockNumber = 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
+ """
+ lines = self.lines[self._selectedLinesSlice()]
+
+ 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 _onShortcutPrint(self):
+ """Ctrl+P handler.
+ Show dialog, print file
+ """
+ dialog = QPrintDialog(self)
+ if dialog.exec_() == QDialog.Accepted:
+ printer = dialog.printer()
+ self.print_(printer)
+
+ def _onCompletion(self):
+ """Ctrl+Space handler.
+ Invoke completer if so configured
+ """
+ if self._completer:
+ self._completer.invokeCompletion()
+
+ def insertFromMimeData(self, source):
+ pass # suppress docstring for non-public method
+ if source.hasFormat(self._rectangularSelection.MIME_TYPE):
+ self._rectangularSelection.paste(source)
+ else:
+ super(Qutepart, self).insertFromMimeData(source)
+
+ def __cursorRect(self, block, column, offset):
+ cursor = QTextCursor(block)
+ setPositionInBlock(cursor, column)
+ return self.cursorRect(cursor).translated(offset, 0)
+
+ def getMargins(self):
+ """Provides the list of margins
+ """
+ return self._margins
+
+ def addMargin(self, margin, index=None):
+ """Adds a new margin.
+ index: index in the list of margins. Default: to the end of the list
+ """
+ if index is None:
+ self._margins.append(margin)
+ else:
+ self._margins.insert(index, margin)
+ if margin.isVisible():
+ self.updateViewport()
+
+ def getMargin(self, name):
+ """Provides the requested margin.
+ Returns a reference to the margin if found and None otherwise
+ """
+ for margin in self._margins:
+ if margin.getName() == name:
+ return margin
+ return None
+
+ def delMargin(self, name):
+ """Deletes a margin.
+ Returns True if the margin was deleted and False otherwise.
+ """
+ for index, margin in enumerate(self._margins):
+ if margin.getName() == name:
+ visible = margin.isVisible()
+ margin.clear()
+ margin.deleteLater()
+ del self._margins[index]
+ if visible:
+ self.updateViewport()
+ return True
+ return False
+
+
+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()
diff --git a/Orange/widgets/data/utils/pythoneditor/htmldelegate.py b/Orange/widgets/data/utils/pythoneditor/htmldelegate.py
new file mode 100644
index 00000000000..81131221b99
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/htmldelegate.py
@@ -0,0 +1,93 @@
+"""
+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.
+"""
+"""
+htmldelegate --- QStyledItemDelegate delegate. Draws HTML
+=========================================================
+"""
+
+from PyQt5.QtWidgets import QApplication, QStyle, QStyledItemDelegate, \
+ QStyleOptionViewItem
+from PyQt5.QtGui import QAbstractTextDocumentLayout, \
+ QTextDocument, QPalette
+from PyQt5.QtCore import QSize
+
+_HTML_ESCAPE_TABLE = \
+{
+ "&": "&",
+ '"': """,
+ "'": "'",
+ ">": ">",
+ "<": "<",
+ " ": " ",
+ "\t": " ",
+}
+
+
+def htmlEscape(text):
+ """Replace special HTML symbols with escase sequences
+ """
+ return "".join(_HTML_ESCAPE_TABLE.get(c,c) for c in text)
+
+
+class HTMLDelegate(QStyledItemDelegate):
+ """QStyledItemDelegate implementation. Draws HTML
+
+ http://stackoverflow.com/questions/1956542/how-to-make-item-view-render-rich-html-text-in-qt/1956781#1956781
+ """
+
+ def paint(self, painter, option, index):
+ """QStyledItemDelegate.paint implementation
+ """
+ option.state &= ~QStyle.State_HasFocus # never draw focus rect
+
+ options = QStyleOptionViewItem(option)
+ self.initStyleOption(options,index)
+
+ style = QApplication.style() if options.widget is None else options.widget.style()
+
+ doc = QTextDocument()
+ doc.setDocumentMargin(1)
+ doc.setHtml(options.text)
+ if options.widget is not None:
+ doc.setDefaultFont(options.widget.font())
+ # bad long (multiline) strings processing doc.setTextWidth(options.rect.width())
+
+ options.text = ""
+ style.drawControl(QStyle.CE_ItemViewItem, options, painter);
+
+ ctx = QAbstractTextDocumentLayout.PaintContext()
+
+ # Highlighting text if item is selected
+ if option.state & QStyle.State_Selected:
+ ctx.palette.setColor(QPalette.Text, option.palette.color(QPalette.Active, QPalette.HighlightedText))
+
+ textRect = style.subElementRect(QStyle.SE_ItemViewItemText, options)
+ painter.save()
+ painter.translate(textRect.topLeft())
+ """Original example contained line
+ painter.setClipRect(textRect.translated(-textRect.topLeft()))
+ but text is drawn clipped with it on kubuntu 12.04
+ """
+ doc.documentLayout().draw(painter, ctx)
+
+ painter.restore()
+
+ def sizeHint(self, option, index):
+ """QStyledItemDelegate.sizeHint implementation
+ """
+ options = QStyleOptionViewItem(option)
+ self.initStyleOption(options,index)
+
+ doc = QTextDocument()
+ doc.setDocumentMargin(1)
+ # bad long (multiline) strings processing doc.setTextWidth(options.rect.width())
+ doc.setHtml(options.text)
+ return QSize(doc.idealWidth(),
+ QStyledItemDelegate.sizeHint(self, option, index).height())
diff --git a/Orange/widgets/data/utils/pythoneditor/indenter/__init__.py b/Orange/widgets/data/utils/pythoneditor/indenter/__init__.py
new file mode 100644
index 00000000000..7b8744e6449
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/indenter/__init__.py
@@ -0,0 +1,242 @@
+"""
+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.
+"""
+"""Module computes indentation for block
+It contains implementation of indenters, which are supported by katepart xml files
+"""
+
+import logging
+
+logger = logging.getLogger('qutepart')
+
+
+from PyQt5.QtGui import QTextCursor
+
+
+def _getSmartIndenter(indenterName, qpart, indenter):
+ """Get indenter by name.
+ Available indenters are none, normal, cstyle, haskell, lilypond, lisp, python, ruby, xml
+ Indenter name is not case sensitive
+ Raise KeyError if not found
+ indentText is indentation, which shall be used. i.e. '\t' for tabs, ' ' for 4 space symbols
+ """
+ indenterName = indenterName.lower()
+
+ if indenterName in ('haskell', 'lilypond'): # not supported yet
+ logger.warning('Smart indentation for %s not supported yet. But you could be a hero who implemented it' % indenterName)
+ from qutepart.indenter.base import IndentAlgNormal as indenterClass
+ elif 'none' == indenterName:
+ from qutepart.indenter.base import IndentAlgBase as indenterClass
+ elif 'normal' == indenterName:
+ from qutepart.indenter.base import IndentAlgNormal as indenterClass
+ elif 'cstyle' == indenterName:
+ from qutepart.indenter.cstyle import IndentAlgCStyle as indenterClass
+ elif 'python' == indenterName:
+ from qutepart.indenter.python import IndentAlgPython as indenterClass
+ elif 'ruby' == indenterName:
+ from qutepart.indenter.ruby import IndentAlgRuby as indenterClass
+ elif 'xml' == indenterName:
+ from qutepart.indenter.xmlindent import IndentAlgXml as indenterClass
+ elif 'haskell' == indenterName:
+ from qutepart.indenter.haskell import IndenterHaskell as indenterClass
+ elif 'lilypond' == indenterName:
+ from qutepart.indenter.lilypond import IndenterLilypond as indenterClass
+ elif 'lisp' == indenterName:
+ from qutepart.indenter.lisp import IndentAlgLisp as indenterClass
+ elif 'scheme' == indenterName:
+ from qutepart.indenter.scheme import IndentAlgScheme as indenterClass
+ else:
+ raise KeyError("Indenter %s not found" % indenterName)
+
+ return indenterClass(qpart, indenter)
+
+
+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 = _getSmartIndenter('normal', self._qpart, self)
+
+ def setSyntax(self, syntax):
+ """Choose smart indentation algorithm according to syntax"""
+ self._smartIndenter = self._chooseSmartIndenter(syntax)
+
+ 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()):
+ endBlock = endBlock.previous() # do not indent not selected line if indenting multiple lines
+
+ 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, '')
+
+ def _chooseSmartIndenter(self, syntax):
+ """Get indenter for syntax
+ """
+ if syntax.indenter is not None:
+ try:
+ return _getSmartIndenter(syntax.indenter, self._qpart, self)
+ except KeyError:
+ logger.error("Indenter '%s' is not finished yet. But you can do it!" % syntax.indenter)
+
+ try:
+ return _getSmartIndenter(syntax.name, self._qpart, self)
+ except KeyError:
+ pass
+
+ return _getSmartIndenter('normal', self._qpart, self)
diff --git a/Orange/widgets/data/utils/pythoneditor/indenter/base.py b/Orange/widgets/data/utils/pythoneditor/indenter/base.py
new file mode 100644
index 00000000000..ac189c5d874
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/indenter/base.py
@@ -0,0 +1,297 @@
+"""
+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.
+"""
+# maximum number of lines we look backwards/forward to find out the indentation
+# level (the bigger the number, the longer might be the delay)
+MAX_SEARCH_OFFSET_LINES = 128
+
+
+class IndentAlgNone:
+ """No any indentation
+ """
+ def __init__(self, qpart):
+ pass
+
+ def computeSmartIndent(self, block, char):
+ return ''
+
+
+class IndentAlgBase(IndentAlgNone):
+ """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 NotImplemented()
+
+ 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 block in cls.iterateBlocksBackFrom(block):
+ for index, char in enumerate(reversed(block.text())):
+ yield block, len(block.text()) - index - 1, char
+
+ def findBracketBackward(self, block, column, bracket):
+ """Search for a needle and return (block, column)
+ Raise ValueError, if not found
+
+ NOTE this method ignores comments
+ """
+ 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
+ else:
+ 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.keys():
+ opening, closing = brackets
+ if char == opening:
+ depth[brackets] -= 1
+ if depth[brackets] == 0:
+ return foundBlock, foundColumn
+ elif char == closing:
+ depth[brackets] += 1
+ else:
+ 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
+
+ def _lastColumn(self, block):
+ """Returns the last non-whitespace column in the given line.
+ If there are only whitespaces in the line, the return value is -1.
+ """
+ text = block.text()
+ index = len(block.text()) - 1
+ while index >= 0 and \
+ (text[index].isspace() or \
+ self._qpart.isComment(block.blockNumber(), index)):
+ index -= 1
+
+ return index
+
+ @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 IndentAlgNormal(IndentAlgBase):
+ """Class automatically computes indentation for lines
+ This is basic indenter, which knows nothing about programming languages
+ """
+ def computeSmartIndent(self, block, char):
+ return self._prevNonEmptyBlockIndent(block)
diff --git a/Orange/widgets/data/utils/pythoneditor/indenter/cstyle.py b/Orange/widgets/data/utils/pythoneditor/indenter/cstyle.py
new file mode 100644
index 00000000000..7dedaaa1485
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/indenter/cstyle.py
@@ -0,0 +1,644 @@
+"""
+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
+
+from qutepart.indenter.base import IndentAlgBase
+
+# User configuration
+CFG_INDENT_CASE = True # indent 'case' and 'default' in a switch?
+CFG_INDENT_NAMESPACE = True # indent after 'namespace'?
+CFG_AUTO_INSERT_STAR = True # auto insert '*' in C-comments
+CFG_SNAP_SLASH = True # snap '/' to '*/' in C-comments
+CFG_AUTO_INSERT_SLACHES = False # auto insert '//' after C++-comments
+CFG_ACCESS_MODIFIERS = 1 # indent level of access modifiers, relative to the class indent level
+ # set to -1 to disable auto-indendation after access modifiers.
+
+# indent gets three arguments: line, indentwidth in spaces, typed character
+# indent
+
+# specifies the characters which should trigger indent, beside the default '\n'
+
+DEBUG_MODE = False
+
+def dbg(*args):
+ if (DEBUG_MODE):
+ print(args)
+
+#global variables and functions
+
+INDENT_WIDTH = 4
+MODE = "C"
+
+
+class IndentAlgCStyle(IndentAlgBase):
+ TRIGGER_CHARACTERS = "{})/:;#"
+
+ @staticmethod
+ def _prevNonEmptyBlock(block):
+ """Reimplemented base indenter level. Skips comments
+ """
+ block = block.previous()
+ while block.isValid() and \
+ (len(block.text().strip()) == 0 or \
+ block.text().startswith('//') or \
+ block.text().startswith('#')):
+ block = block.previous()
+
+ return block
+
+ def findTextBackward(self, block, column, needle):
+ """Search for a needle and return (block, column)
+ Raise ValueError, if not found
+ """
+ if column is not None:
+ index = block.text()[:column].rfind(needle)
+ else:
+ index = block.text().rfind(needle)
+
+ if index != -1:
+ return block, index
+
+ for block in self.iterateBlocksBackFrom(block.previous()):
+ column = block.text().rfind(needle)
+ if column != -1:
+ return block, column
+
+ raise ValueError('Not found')
+
+ def findLeftBrace(self, block, column):
+ """Search for a corresponding '{' and return its indentation
+ If not found return None
+ """
+ block, column = self.findBracketBackward(block, column, '{') # raise ValueError if not found
+
+ try:
+ block, column = self.tryParenthesisBeforeBrace(block, column)
+ except ValueError:
+ pass # leave previous values
+ return self._blockIndent(block)
+
+ def tryParenthesisBeforeBrace(self, block, column):
+ """ Character at (block, column) has to be a '{'.
+ Now try to find the right line for indentation for constructs like:
+ if (a == b
+ and c == d) { <- check for ')', and find '(', then return its indentation
+ Returns input params, if no success, otherwise block and column of '('
+ """
+ text = block.text()[:column - 1].rstrip()
+ if not text.endswith(')'):
+ raise ValueError()
+ return self.findBracketBackward(block, len(text) - 1, '(')
+
+ def trySwitchStatement(self, block):
+ """Check for default and case keywords and assume we are in a switch statement.
+ Try to find a previous default, case or switch and return its indentation or
+ None if not found.
+ """
+ if not re.match(r'^\s*(default\s*|case\b.*):', block.text()):
+ return None
+
+ for block in self.iterateBlocksBackFrom(block.previous()):
+ text = block.text()
+ if re.match(r"^\s*(default\s*|case\b.*):", text):
+ dbg("trySwitchStatement: success in line %d" % block.blockNumber())
+ return self._lineIndent(text)
+ elif re.match(r"^\s*switch\b", text):
+ if CFG_INDENT_CASE:
+ return self._increaseIndent(self._lineIndent(text))
+ else:
+ return self._lineIndent(text)
+
+ return None
+
+ def tryAccessModifiers(self, block):
+ """Check for private, protected, public, signals etc... and assume we are in a
+ class definition. Try to find a previous private/protected/private... or
+ class and return its indentation or null if not found.
+ """
+
+ if CFG_ACCESS_MODIFIERS < 0:
+ return None
+
+ if not re.match(r'^\s*((public|protected|private)\s*(slots|Q_SLOTS)?|(signals|Q_SIGNALS)\s*):\s*$', block.text()):
+ return None
+
+ try:
+ block, notUsedColumn = self.findBracketBackward(block, 0, '{')
+ except ValueError:
+ return None
+
+ indentation = self._blockIndent(block)
+ for i in range(CFG_ACCESS_MODIFIERS):
+ indentation = self._increaseIndent(indentation)
+
+ dbg("tryAccessModifiers: success in line %d" % block.blockNumber())
+ return indentation
+
+ def tryCComment(self, block):
+ """C comment checking. If the previous line begins with a "/*" or a "* ", then
+ return its leading white spaces + ' *' + the white spaces after the *
+ return: filler string or null, if not in a C comment
+ """
+ indentation = None
+
+ prevNonEmptyBlock = self._prevNonEmptyBlock(block)
+ if not prevNonEmptyBlock.isValid():
+ return None
+
+ prevNonEmptyBlockText = prevNonEmptyBlock.text()
+
+ if prevNonEmptyBlockText.endswith('*/'):
+ try:
+ foundBlock, notUsedColumn = self.findTextBackward(prevNonEmptyBlock, prevNonEmptyBlock.length(), '/*')
+ except ValueError:
+ foundBlock = None
+
+ if foundBlock is not None:
+ dbg("tryCComment: success (1) in line %d" % foundBlock.blockNumber())
+ return self._lineIndent(foundBlock.text())
+
+ if prevNonEmptyBlock != block.previous():
+ # inbetween was an empty line, so do not copy the "*" character
+ return None
+
+ blockTextStripped = block.text().strip()
+ prevBlockTextStripped = prevNonEmptyBlockText.strip()
+
+ if prevBlockTextStripped.startswith('/*') and not '*/' in prevBlockTextStripped:
+ indentation = self._blockIndent(prevNonEmptyBlock)
+ if CFG_AUTO_INSERT_STAR:
+ # only add '*', if there is none yet.
+ indentation += ' '
+ if not blockTextStripped.endswith('*'):
+ indentation += '*'
+ secondCharIsSpace = len(blockTextStripped) > 1 and blockTextStripped[1].isspace()
+ if not secondCharIsSpace and \
+ not blockTextStripped.endswith("*/"):
+ indentation += ' '
+ dbg("tryCComment: success (2) in line %d" % block.blockNumber())
+ return indentation
+
+ elif prevBlockTextStripped.startswith('*') and \
+ (len(prevBlockTextStripped) == 1 or prevBlockTextStripped[1].isspace()):
+
+ # in theory, we could search for opening /*, and use its indentation
+ # and then one alignment character. Let's not do this for now, though.
+ indentation = self._lineIndent(prevNonEmptyBlockText)
+ # only add '*', if there is none yet.
+ if CFG_AUTO_INSERT_STAR and not blockTextStripped.startswith('*'):
+ indentation += '*'
+ if len(blockTextStripped) < 2 or not blockTextStripped[1].isspace():
+ indentation += ' '
+
+ dbg("tryCComment: success (2) in line %d" % block.blockNumber())
+ return indentation
+
+ return None
+
+ def tryCppComment(self, block):
+ """C++ comment checking. when we want to insert slashes:
+ #, #/, #! #/<, #!< and ##...
+ return: filler string or null, if not in a star comment
+ NOTE: otherwise comments get skipped generally and we use the last code-line
+ """
+ if not block.previous().isValid() or \
+ not CFG_AUTO_INSERT_SLACHES:
+ return None
+
+ prevLineText = block.previous().text()
+
+ indentation = None
+ comment = prevLineText.lstrip().startswith('#')
+
+ # allowed are: #, #/, #! #/<, #!< and ##...
+ if comment:
+ prevLineText = block.previous().text()
+ lstrippedText = block.previous().text().lstrip()
+ if len(lstrippedText) >= 4:
+ char3 = lstrippedText[2]
+ char4 = lstrippedText[3]
+
+ indentation = self._lineIndent(prevLineText)
+
+ if CFG_AUTO_INSERT_SLACHES:
+ if prevLineText[2:4] == '//':
+ # match ##... and replace by only two: #
+ match = re.match(r'^\s*(\/\/)', prevLineText)
+ elif (char3 == '/' or char3 == '!'):
+ # match #/, #!, #/< and #!
+ match = re.match(r'^\s*(\/\/[\/!][<]?\s*)', prevLineText)
+ else:
+ # only #, nothing else:
+ match = re.match(r'^\s*(\/\/\s*)', prevLineText)
+
+ if match is not None:
+ self._qpart.insertText((block.blockNumber(), 0), match.group(1))
+
+ if indentation is not None:
+ dbg("tryCppComment: success in line %d" % block.previous().blockNumber())
+
+ return indentation
+
+ def tryBrace(self, block):
+ def _isNamespace(block):
+ if not block.text().strip():
+ block = block.previous()
+
+ return re.match(r'^\s*namespace\b', block.text()) is not None
+
+ currentBlock = self._prevNonEmptyBlock(block)
+ if not currentBlock.isValid():
+ return None
+
+ indentation = None
+
+ if currentBlock.text().rstrip().endswith('{'):
+ try:
+ foundBlock, notUsedColumn = self.tryParenthesisBeforeBrace(currentBlock, len(currentBlock.text().rstrip()))
+ except ValueError: # not found
+ indentation = self._blockIndent(currentBlock)
+ if CFG_INDENT_NAMESPACE or not _isNamespace(block):
+ # take its indentation and add one indentation level
+ indentation = self._increaseIndent(indentation)
+ else: # found
+ indentation = self._increaseIndent(self._blockIndent(foundBlock))
+
+
+ if indentation is not None:
+ dbg("tryBrace: success in line %d" % block.blockNumber())
+ return indentation
+
+ def tryCKeywords(self, block, isBrace):
+ """
+ Check for if, else, while, do, switch, private, public, protected, signals,
+ default, case etc... keywords, as we want to indent then. If is
+ non-null/True, then indentation is not increased.
+ Note: The code is written to be called *after* tryCComment and tryCppComment!
+ """
+ currentBlock = self._prevNonEmptyBlock(block)
+ if not currentBlock.isValid():
+ return None
+
+ # if line ends with ')', find the '(' and check this line then.
+
+ if currentBlock.text().rstrip().endswith(')'):
+ try:
+ foundBlock, foundColumn = self.findBracketBackward(currentBlock, len(currentBlock.text()), '(')
+ except ValueError:
+ pass
+ else:
+ currentBlock = foundBlock
+
+ # found non-empty line
+ currentBlockText = currentBlock.text()
+ if re.match(r'^\s*(if\b|for|do\b|while|switch|[}]?\s*else|((private|public|protected|case|default|signals|Q_SIGNALS).*:))', currentBlockText) is None:
+ return None
+
+ indentation = None
+
+ # ignore trailing comments see: https:#bugs.kde.org/show_bug.cgi?id=189339
+ try:
+ index = currentBlockText.index('//')
+ except ValueError:
+ pass
+ else:
+ currentBlockText = currentBlockText[:index]
+
+ # try to ignore lines like: if (a) b; or if (a) { b; }
+ if not currentBlockText.endswith(';') and \
+ not currentBlockText.endswith('}'):
+ # take its indentation and add one indentation level
+ indentation = self._lineIndent(currentBlockText)
+ if not isBrace:
+ indentation = self._increaseIndent(indentation)
+ elif currentBlockText.endswith(';'):
+ # stuff like:
+ # for(int b;
+ # b < 10;
+ # --b)
+ try:
+ foundBlock, foundColumn = self.findBracketBackward(currentBlock, None, '(')
+ except ValueError:
+ pass
+ else:
+ dbg("tryCKeywords: success 1 in line %d" % block.blockNumber())
+ return self._makeIndentAsColumn(foundBlock, foundColumn, 1)
+ if indentation is not None:
+ dbg("tryCKeywords: success in line %d" % block.blockNumber())
+
+ return indentation
+
+ def tryCondition(self, block):
+ """ Search for if, do, while, for, ... as we want to indent then.
+ Return null, if nothing useful found.
+ Note: The code is written to be called *after* tryCComment and tryCppComment!
+ """
+ currentBlock = self._prevNonEmptyBlock(block)
+ if not currentBlock.isValid():
+ return None
+
+ # found non-empty line
+ currentText = currentBlock.text()
+ if currentText.rstrip().endswith(';') and \
+ re.search(r'^\s*(if\b|[}]?\s*else|do\b|while\b|for)', currentText) is None:
+ # idea: we had something like:
+ # if/while/for (expression)
+ # statement(); <-- we catch this trailing ';'
+ # Now, look for a line that starts with if/for/while, that has one
+ # indent level less.
+ currentIndentation = self._lineIndent(currentText)
+ if not currentIndentation:
+ return None
+
+ for block in self.iterateBlocksBackFrom(currentBlock.previous()):
+ if block.text().strip(): # not empty
+ indentation = self._blockIndent(block)
+
+ if len(indentation) < len(currentIndentation):
+ if re.search(r'^\s*(if\b|[}]?\s*else|do\b|while\b|for)[^{]*$', block.text()) is not None:
+ dbg("tryCondition: success in line %d" % block.blockNumber())
+ return indentation
+ break
+
+ return None
+
+ def tryStatement(self, block):
+ """ If the non-empty line ends with ); or ',', then search for '(' and return its
+ indentation; also try to ignore trailing comments.
+ """
+ currentBlock = self._prevNonEmptyBlock(block)
+
+ if not currentBlock.isValid():
+ return None
+
+ indentation = None
+
+ currentBlockText = currentBlock.text()
+ if currentBlockText.endswith('('):
+ # increase indent level
+ dbg("tryStatement: success 1 in line %d" % block.blockNumber())
+ return self._increaseIndent(self._lineIndent(currentBlockText))
+
+ alignOnSingleQuote = self._qpart.language() in ('PHP/PHP', 'JavaScript')
+ # align on strings "..."\n => below the opening quote
+ # multi-language support: [\.+] for javascript or php
+ pattern = '^(.*)' # any group 1
+ pattern += '([,"\'\\)])' # one of [ , " ' ) group 2
+ pattern += '(;?)' # optional ; group 3
+ pattern += '\s*[\.+]?\s*' # optional spaces optional . or + optional spaces
+ pattern += '(//.*|/\\*.*\\*/\s*)?$' # optional(//any or /*any*/spaces) group 4
+ match = re.match(pattern, currentBlockText)
+ if match is not None:
+ alignOnAnchor = len(match.group(3)) == 0 and match.group(2) != ')'
+ # search for opening ", ' or (
+ if match.group(2) == '"' or (alignOnSingleQuote and match.group(2) == "'"):
+ startIndex = len(match.group(1))
+ while True:
+ # start from matched closing ' or "
+ # find string opener
+ for i in range(startIndex - 1, 0, -1):
+ # make sure it's not commented out
+ if currentBlockText[i] == match.group(2) and (i == 0 or currentBlockText[i - 1] != '\\'):
+ # also make sure that this is not a line like '#include "..."' <-- we don't want to indent here
+ if re.match(r'^#include', currentBlockText):
+ dbg("tryStatement: success 2 in line %d" % block.blockNumber())
+ return indentation
+
+ break
+
+ if not alignOnAnchor and currentBlock.previous().isValid():
+ # when we finished the statement (;) we need to get the first line and use it's indentation
+ # i.e.: $foo = "asdf"; -> align on $
+ i -= 1 # skip " or '
+ # skip whitespaces and stuff like + or . (for PHP, JavaScript, ...)
+ for i in range(i, 0, -1):
+ if currentBlockText[i] in (' ', '\t', '.', '+'):
+ continue
+ else:
+ break
+
+ if i > 0:
+ # there's something in this line, use it's indentation
+ break
+ else:
+ # go to previous line
+ currentBlock = currentBlock.previous()
+ currentBlockText = currentBlock.text()
+ startIndex = len(currentBlockText)
+ else:
+ break
+
+ elif match.group(2) == ',' and not '(' in currentBlockText:
+ # assume a function call: check for '(' brace
+ # - if not found, use previous indentation
+ # - if found, compare the indentation depth of current line and open brace line
+ # - if current indentation depth is smaller, use that
+ # - otherwise, use the '(' indentation + following white spaces
+ currentIndentation = self._blockIndent(currentBlock)
+ try:
+ foundBlock, foundColumn = self.findBracketBackward(currentBlock, len(match.group(1)), '(')
+ except ValueError:
+ indentation = currentIndentation
+ else:
+ indentWidth = foundColumn + 1
+ text = foundBlock.text()
+ while indentWidth < len(text) and text[indentWidth].isspace():
+ indentWidth += 1
+ indentation = self._makeIndentAsColumn(foundBlock, indentWidth)
+
+ else:
+ try:
+ foundBlock, foundColumn = self.findBracketBackward(currentBlock, len(match.group(1)), '(')
+ except ValueError:
+ pass
+ else:
+ if alignOnAnchor:
+ if not match.group(2) in ('"', "'"):
+ foundColumn += 1
+ foundBlockText = foundBlock.text()
+ while foundColumn < len(foundBlockText) and \
+ foundBlockText[foundColumn].isspace():
+ foundColumn += 1
+ indentation = self._makeIndentAsColumn(foundBlock, foundColumn)
+ else:
+ currentBlock = foundBlock
+ indentation = self._blockIndent(currentBlock)
+ elif currentBlockText.rstrip().endswith(';'):
+ indentation = self._blockIndent(currentBlock)
+
+ if indentation is not None:
+ dbg("tryStatement: success in line %d" % currentBlock.blockNumber())
+ return indentation
+
+ def tryMatchedAnchor(self, block, autoIndent):
+ """
+ find out whether we pressed return in something like {} or () or [] and indent properly:
+ {}
+ becomes:
+ {
+ |
+ }
+ """
+ oposite = { ')': '(',
+ '}': '{',
+ ']': '['}
+
+ char = self._firstNonSpaceChar(block)
+ if not char in oposite.keys():
+ return None
+
+ # we pressed enter in e.g. ()
+ try:
+ foundBlock, foundColumn = self.findBracketBackward(block, 0, oposite[char])
+ except ValueError:
+ return None
+
+ if autoIndent:
+ # when aligning only, don't be too smart and just take the indent level of the open anchor
+ return self._blockIndent(foundBlock)
+
+ lastChar = self._lastNonSpaceChar(block.previous())
+ charsMatch = ( lastChar == '(' and char == ')' ) or \
+ ( lastChar == '{' and char == '}' ) or \
+ ( lastChar == '[' and char == ']' )
+
+ indentation = None
+ if (not charsMatch) and char != '}':
+ # otherwise check whether the last line has the expected
+ # indentation, if not use it instead and place the closing
+ # anchor on the level of the opening anchor
+ expectedIndentation = self._increaseIndent(self._blockIndent(foundBlock))
+ actualIndentation = self._increaseIndent(self._blockIndent(block.previous()))
+ indentation = None
+ if len(expectedIndentation) <= len(actualIndentation):
+ if lastChar == ',':
+ # use indentation of last line instead and place closing anchor
+ # in same column of the opening anchor
+ self._qpart.insertText((block.blockNumber(), self._firstNonSpaceColumn(block.text())), '\n')
+ self._qpart.cursorPosition = (block.blockNumber(), len(actualIndentation))
+ # indent closing anchor
+ self._setBlockIndent(block.next(), self._makeIndentAsColumn(foundBlock, foundColumn))
+ indentation = actualIndentation
+ elif expectedIndentation == self._blockIndent(block.previous()):
+ # otherwise don't add a new line, just use indentation of closing anchor line
+ indentation = self._blockIndent(foundBlock)
+ else:
+ # otherwise don't add a new line, just align on closing anchor
+ indentation = self._makeIndentAsColumn(foundBlock, foundColumn)
+
+ dbg("tryMatchedAnchor: success in line %d" % foundBlock.blockNumber())
+ return indentation
+
+ # otherwise we i.e. pressed enter between (), [] or when we enter before curly brace
+ # increase indentation and place closing anchor on the next line
+ indentation = self._blockIndent(foundBlock)
+ self._qpart.replaceText((block.blockNumber(), 0), len(self._blockIndent(block)), "\n")
+ self._qpart.cursorPosition = (block.blockNumber(), len(indentation))
+ # indent closing brace
+ self._setBlockIndent(block.next(), indentation)
+ dbg("tryMatchedAnchor: success in line %d" % foundBlock.blockNumber())
+ return self._increaseIndent(indentation)
+
+ def indentLine(self, block, autoIndent):
+ """ Indent line.
+ Return filler or null.
+ """
+ indent = None
+ if indent is None:
+ indent = self.tryMatchedAnchor(block, autoIndent)
+ if indent is None:
+ indent = self.tryCComment(block)
+ if indent is None and not autoIndent:
+ indent = self.tryCppComment(block)
+ if indent is None:
+ indent = self.trySwitchStatement(block)
+ if indent is None:
+ indent = self.tryAccessModifiers(block)
+ if indent is None:
+ indent = self.tryBrace(block)
+ if indent is None:
+ indent = self.tryCKeywords(block, block.text().lstrip().startswith('{'))
+ if indent is None:
+ indent = self.tryCondition(block)
+ if indent is None:
+ indent = self.tryStatement(block)
+
+ if indent is not None:
+ return indent
+ else:
+ dbg("Nothing matched")
+ return self._prevNonEmptyBlockIndent(block)
+
+ def processChar(self, block, c):
+ if c == ';' or (not (c in self.TRIGGER_CHARACTERS)):
+ return self._blockIndent(block)
+
+ column = self._qpart.cursorPosition[1]
+ blockIndent = self._blockIndent(block)
+ firstCharAfterIndent = column == (len(blockIndent) + 1)
+
+ if firstCharAfterIndent and c == '{':
+ # todo: maybe look for if etc.
+ indent = self.tryBrace(block)
+ if indent is None:
+ indent = self.tryCKeywords(block, True)
+ if indent is None:
+ indent = self.tryCComment(block); # checks, whether we had a "*/"
+ if indent is None:
+ indent = self.tryStatement(block)
+ if indent is None:
+ indent = blockIndent
+
+ return indent
+ elif firstCharAfterIndent and c == '}':
+ try:
+ indentation = self.findLeftBrace(block, self._firstNonSpaceColumn(block.text()))
+ except ValueError:
+ return blockIndent
+ else:
+ return indentation
+ elif CFG_SNAP_SLASH and c == '/' and block.text().endswith(' /'):
+ # try to snap the string "* /" to "*/"
+ match = re.match(r'^(\s*)\*\s+\/\s*$', block.text())
+ if match is not None:
+ self._qpart.lines[block.blockNumber()] = match.group(1) + '*/'
+ dbg("snapSlash at block %d" % block.blockNumber())
+ return blockIndent
+ elif c == ':':
+ # todo: handle case, default, signals, private, public, protected, Q_SIGNALS
+ indent = self.trySwitchStatement(block)
+ if indent is None:
+ indent = self.tryAccessModifiers(block)
+ if indent is None:
+ indent = blockIndent
+ return indent
+ elif c == ')' and firstCharAfterIndent:
+ # align on start of identifier of function call
+ try:
+ foundBlock, foundColumn = self.findBracketBackward(block, column - 1, '(')
+ except ValueError:
+ pass
+ else:
+ text = foundBlock.text()[:foundColumn]
+ match = re.search(r'\b(\w+)\s*$', text)
+ if match is not None:
+ return self._makeIndentAsColumn(foundBlock, match.start())
+ elif firstCharAfterIndent and c == '#' and self._qpart.language() in ('C', 'C++'):
+ # always put preprocessor stuff upfront
+ return ''
+ return blockIndent
+
+ def computeSmartIndent(self, block, char):
+ autoIndent = char == ""
+
+ if char != '\n' and not autoIndent:
+ return self.processChar(block, char)
+
+ return self.indentLine(block, autoIndent)
diff --git a/Orange/widgets/data/utils/pythoneditor/indenter/lisp.py b/Orange/widgets/data/utils/pythoneditor/indenter/lisp.py
new file mode 100644
index 00000000000..be5dfe10e16
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/indenter/lisp.py
@@ -0,0 +1,35 @@
+"""
+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
+
+from qutepart.indenter.base import IndentAlgBase
+
+class IndentAlgLisp(IndentAlgBase):
+ TRIGGER_CHARACTERS = ";"
+
+ def computeSmartIndent(self, block, ch):
+ """special rules: ;;; -> indent 0
+ ;; -> align with next line, if possible
+ ; -> usually on the same line as code -> ignore
+ """
+ if re.search(r'^\s*;;;', block.text()):
+ return ''
+ elif re.search(r'^\s*;;', block.text()):
+ #try to align with the next line
+ nextBlock = self._nextNonEmptyBlock(block)
+ if nextBlock.isValid():
+ return self._blockIndent(nextBlock)
+
+ try:
+ foundBlock, foundColumn = self.findBracketBackward(block, 0, '(')
+ except ValueError:
+ return ''
+ else:
+ return self._increaseIndent(self._blockIndent(foundBlock))
diff --git a/Orange/widgets/data/utils/pythoneditor/indenter/python.py b/Orange/widgets/data/utils/pythoneditor/indenter/python.py
new file mode 100644
index 00000000000..aabfc339e76
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/indenter/python.py
@@ -0,0 +1,107 @@
+"""
+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 qutepart.indenter.base import IndentAlgBase
+
+
+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:
+ foundBlock, foundColumn = self.findBracketBackward(block,
+ spaceLen + len(lineStripped) - 1,
+ lineStripped[-1])
+ 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/indenter/ruby.py b/Orange/widgets/data/utils/pythoneditor/indenter/ruby.py
new file mode 100644
index 00000000000..5cb788db64b
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/indenter/ruby.py
@@ -0,0 +1,297 @@
+"""
+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 qutepart.indenter.base import IndentAlgBase
+
+import re
+
+# Indent after lines that match this regexp
+rxIndent = re.compile(r'^\s*(def|if|unless|for|while|until|class|module|else|elsif|case|when|begin|rescue|ensure|catch)\b')
+
+# Unindent lines that match this regexp
+rxUnindent = re.compile(r'^\s*((end|when|else|elsif|rescue|ensure)\b|[\]\}])(.*)$')
+
+rxBlockEnd = re.compile(r'\s*end$')
+
+
+class Statement:
+ def __init__(self, qpart, startBlock, endBlock):
+ self._qpart = qpart
+ self.startBlock = startBlock
+ self.endBlock = endBlock
+
+ # Convert to string for debugging
+ def __str__(self):
+ return "{ %d, %d}" % (self.startBlock.blockNumber(), self.endBlock.blockNumber())
+
+ def offsetToCursor(self, offset):
+ # Return (block, column)
+ # TODO Provide helper function for this when API is converted to using cursors:
+ block = self.startBlock
+ while block != self.endBlock.next() and \
+ len(block.text()) < offset:
+ offset -= len(block.text())
+ block = block.next()
+
+ return block, offset
+
+ def isCode(self, offset):
+ # Return document.isCode at the given offset in a statement
+ block, column = self.offsetToCursor(offset)
+ return self._qpart.isCode(block.blockNumber(), column)
+
+ def isComment(self, offset):
+ # Return document.isComment at the given offset in a statement
+ block, column = self.offsetToCursor(offset)
+ return self._qpart.isComment(block.blockNumber(), column)
+
+ def indent(self):
+ # Return the indent at the beginning of the statement
+ return IndentAlgRuby._blockIndent(self.startBlock)
+
+ def content(self):
+ # Return the content of the statement from the document
+ cnt = ""
+ block = self.startBlock
+ while block != self.endBlock.next():
+ text = block.text()
+ if text.endswith('\\'):
+ cnt += text[:-1]
+ cnt += ' '
+ else:
+ cnt += text
+ block = block.next()
+ return cnt
+
+
+class IndentAlgRuby(IndentAlgBase):
+ """Indenter for Ruby
+ """
+ TRIGGER_CHARACTERS = "cdefhilnrsuw}]"
+
+ def _isCommentBlock(self, block):
+ text = block.text()
+ firstColumn = self._firstNonSpaceColumn(text)
+ return firstColumn == len(text) or self._isComment(block, firstColumn)
+
+ def _isComment(self, block, column):
+ return self._qpart.isComment(block.blockNumber(), column)
+
+ def _prevNonCommentBlock(self, block):
+ """Return the closest non-empty line, ignoring comments
+ (result <= line). Return -1 if the document
+ """
+ block = self._prevNonEmptyBlock(block)
+ while block.isValid() and self._isCommentBlock(block):
+ block = self._prevNonEmptyBlock(block)
+ return block
+
+ @staticmethod
+ def _isBlockContinuing(block):
+ return block.text().endswith('\\')
+
+ def _isLastCodeColumn(self, block, column):
+ """Return true if the given column is at least equal to the column that
+ contains the last non-whitespace character at the given line, or if
+ the rest of the line is a comment.
+ """
+ return column >= self._lastColumn(block) or \
+ self._isComment(block, self._nextNonSpaceColumn(block, column + 1))
+
+ @staticmethod
+ def testAtEnd(stmt, rx):
+ """Look for a pattern at the end of the statement.
+
+ Returns true if the pattern is found, in a position
+ that is not inside a string or a comment, and the position +
+ the length of the matching part is either the end of the
+ statement, or a comment.
+
+ The regexp must be global, and the search is continued until
+ a match is found, or the end of the string is reached.
+ """
+ for match in rx.finditer(stmt.content()):
+ if stmt.isCode(match.start()):
+ if match.end() == len(stmt.content()):
+ return True
+ if stmt.isComment(match.end()):
+ return True
+ else:
+ return False
+
+ def lastAnchor(self, block, column):
+ """Find the last open bracket before the current line.
+ Return (block, column, char) or (None, None, None)
+ """
+ currentPos = -1
+ currentBlock = None
+ currentColumn = None
+ currentChar = None
+ for char in '({[':
+ try:
+ foundBlock, foundColumn = self.findBracketBackward(block, column, char)
+ except ValueError:
+ continue
+ else:
+ pos = foundBlock.position() + foundColumn
+ if pos > currentPos:
+ currentBlock = foundBlock
+ currentColumn = foundColumn
+ currentChar = char
+ currentPos = pos
+
+ return currentBlock, currentColumn, currentChar
+
+ def isStmtContinuing(self, block):
+ #Is there an open parenthesis?
+
+ foundBlock, foundColumn, foundChar = self.lastAnchor(block, block.length())
+ if foundBlock is not None:
+ return True
+
+ stmt = Statement(self._qpart, block, block)
+ rx = re.compile(r'(\+|\-|\*|\/|\=|&&|\|\||\band\b|\bor\b|,)\s*')
+ return self.testAtEnd(stmt, rx)
+
+ def findStmtStart(self, block):
+ """Return the first line that is not preceded by a "continuing" line.
+ Return currBlock if currBlock <= 0
+ """
+ prevBlock = self._prevNonCommentBlock(block)
+ while prevBlock.isValid() and \
+ (((prevBlock == block.previous()) and self._isBlockContinuing(prevBlock)) or \
+ self.isStmtContinuing(prevBlock)):
+ block = prevBlock
+ prevBlock = self._prevNonCommentBlock(block)
+ return block
+
+ @staticmethod
+ def _isValidTrigger(block, ch):
+ """check if the trigger characters are in the right context,
+ otherwise running the indenter might be annoying to the user
+ """
+ if ch == "" or ch == "\n":
+ return True # Explicit align or new line
+
+ match = rxUnindent.match(block.text())
+ return match is not None and \
+ match.group(3) == ""
+
+ def findPrevStmt(self, block):
+ """Returns a tuple that contains the first and last line of the
+ previous statement before line.
+ """
+ stmtEnd = self._prevNonCommentBlock(block)
+ stmtStart = self.findStmtStart(stmtEnd)
+ return Statement(self._qpart, stmtStart, stmtEnd)
+
+ def isBlockStart(self, stmt):
+ if rxIndent.search(stmt.content()):
+ return True
+
+ rx = re.compile(r'((\bdo\b|\{)(\s*\|.*\|)?\s*)')
+
+ return self.testAtEnd(stmt, rx)
+
+ @staticmethod
+ def isBlockEnd(stmt):
+ return rxUnindent.match(stmt.content())
+
+ def findBlockStart(self, block):
+ nested = 0
+ stmt = Statement(self._qpart, block, block)
+ while True:
+ if not stmt.startBlock.isValid():
+ return stmt
+ stmt = self.findPrevStmt(stmt.startBlock)
+ if self.isBlockEnd(stmt):
+ nested += 1
+
+ if self.isBlockStart(stmt):
+ if nested == 0:
+ return stmt
+ else:
+ nested -= 1
+
+ def computeSmartIndent(self, block, ch):
+ """indent gets three arguments: line, indentWidth in spaces,
+ typed character indent
+ """
+ if not self._isValidTrigger(block, ch):
+ return None
+
+ prevStmt = self.findPrevStmt(block)
+ if not prevStmt.endBlock.isValid():
+ return None # Can't indent the first line
+
+ prevBlock = self._prevNonEmptyBlock(block)
+
+ # HACK Detect here documents
+ if self._qpart.isHereDoc(prevBlock.blockNumber(), prevBlock.length() - 2):
+ return None
+
+ # HACK Detect embedded comments
+ if self._qpart.isBlockComment(prevBlock.blockNumber(), prevBlock.length() - 2):
+ return None
+
+ prevStmtCnt = prevStmt.content()
+ prevStmtInd = prevStmt.indent()
+
+ # Are we inside a parameter list, array or hash?
+ foundBlock, foundColumn, foundChar = self.lastAnchor(block, 0)
+ if foundBlock is not None:
+ shouldIndent = foundBlock == prevStmt.endBlock or \
+ self.testAtEnd(prevStmt, re.compile(',\s*'))
+ if (not self._isLastCodeColumn(foundBlock, foundColumn)) or \
+ self.lastAnchor(foundBlock, foundColumn)[0] is not None:
+ # TODO This is alignment, should force using spaces instead of tabs:
+ if shouldIndent:
+ foundColumn += 1
+ nextCol = self._nextNonSpaceColumn(foundBlock, foundColumn)
+ if nextCol > 0 and \
+ (not self._isComment(foundBlock, nextCol)):
+ foundColumn = nextCol
+
+ # Keep indent of previous statement, while aligning to the anchor column
+ if len(prevStmtInd) > foundColumn:
+ return prevStmtInd
+ else:
+ return self._makeIndentAsColumn(foundBlock, foundColumn)
+ else:
+ indent = self._blockIndent(foundBlock)
+ if shouldIndent:
+ indent = self._increaseIndent(indent)
+ return indent
+
+ # Handle indenting of multiline statements.
+ if (prevStmt.endBlock == block.previous() and \
+ self._isBlockContinuing(prevStmt.endBlock)) or \
+ self.isStmtContinuing(prevStmt.endBlock):
+ if prevStmt.startBlock == prevStmt.endBlock:
+ if ch == '' and \
+ len(self._blockIndent(block)) > len(self._blockIndent(prevStmt.endBlock)):
+ return None # Don't force a specific indent level when aligning manually
+ return self._increaseIndent(self._increaseIndent(prevStmtInd))
+ else:
+ return self._blockIndent(prevStmt.endBlock)
+
+ if rxUnindent.match(block.text()):
+ startStmt = self.findBlockStart(block)
+ if startStmt.startBlock.isValid():
+ return startStmt.indent()
+ else:
+ return None
+
+ if self.isBlockStart(prevStmt) and not rxBlockEnd.search(prevStmt.content()):
+ return self._increaseIndent(prevStmtInd)
+ elif re.search(r'[\[\{]\s*$', prevStmtCnt) is not None:
+ return self._increaseIndent(prevStmtInd)
+
+ # Keep current
+ return prevStmtInd
diff --git a/Orange/widgets/data/utils/pythoneditor/indenter/scheme.py b/Orange/widgets/data/utils/pythoneditor/indenter/scheme.py
new file mode 100644
index 00000000000..32a8d78d17c
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/indenter/scheme.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.
+"""
+"""This indenter works according to
+ http://community.schemewiki.org/?scheme-style
+
+TODO support (module
+"""
+
+from qutepart.indenter.base import IndentAlgBase
+
+
+class IndentAlgScheme(IndentAlgBase):
+ """Indenter for Scheme files
+ """
+ TRIGGER_CHARACTERS = ""
+
+ def _findExpressionEnd(self, block):
+ """Find end of the last expression
+ """
+ while block.isValid():
+ column = self._lastColumn(block)
+ if column > 0:
+ return block, column
+ block = block.previous()
+ raise UserWarning()
+
+ def _lastWord(self, text):
+ """Move backward to the start of the word at the end of a string.
+ Return the word
+ """
+ for index, char in enumerate(text[::-1]):
+ if char.isspace() or \
+ char in ('(', ')'):
+ return text[len(text) - index :]
+ else:
+ return text
+
+ def _findExpressionStart(self, block):
+ """Find start of not finished expression
+ Raise UserWarning, if not found
+ """
+
+ # raise expession on next level, if not found
+ expEndBlock, expEndColumn = self._findExpressionEnd(block)
+
+ text = expEndBlock.text()[:expEndColumn + 1]
+ if text.endswith(')'):
+ try:
+ return self.findBracketBackward(expEndBlock, expEndColumn, '(')
+ except ValueError:
+ raise UserWarning()
+ else:
+ return expEndBlock, len(text) - len(self._lastWord(text))
+
+ def computeSmartIndent(self, block, char):
+ """Compute indent for the block
+ """
+ try:
+ foundBlock, foundColumn = self._findExpressionStart(block.previous())
+ except UserWarning:
+ return ''
+ expression = foundBlock.text()[foundColumn:].rstrip()
+ beforeExpression = foundBlock.text()[:foundColumn].strip()
+
+ if beforeExpression.startswith('(module'): # special case
+ return ''
+ elif beforeExpression.endswith('define'): # special case
+ return ' ' * (len(beforeExpression) - len('define') + 1)
+ elif beforeExpression.endswith('let'): # special case
+ return ' ' * (len(beforeExpression) - len('let') + 1)
+ else:
+ return ' ' * foundColumn
diff --git a/Orange/widgets/data/utils/pythoneditor/indenter/xmlindent.py b/Orange/widgets/data/utils/pythoneditor/indenter/xmlindent.py
new file mode 100644
index 00000000000..0093e630465
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/indenter/xmlindent.py
@@ -0,0 +1,105 @@
+"""
+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
+
+from qutepart.indenter.base import IndentAlgBase
+
+class IndentAlgXml(IndentAlgBase):
+ """Indenter for XML files
+ """
+ TRIGGER_CHARACTERS = "/>"
+
+ def computeSmartIndent(self, block, char):
+ """Compute indent for the block
+ """
+ lineText = block.text()
+ prevLineText = self._prevNonEmptyBlock(block).text()
+
+ alignOnly = char == ''
+
+ if alignOnly:
+ # XML might be all in one line, in which case we want to break that up.
+ tokens = re.split(r'>\s*<', lineText)
+
+ if len(tokens) > 1:
+
+ prevIndent = self._lineIndent(prevLineText)
+
+ for index, newLine in enumerate(tokens):
+ if index > 0:
+ newLine = '<' + newLine
+
+ if index < len(tokens) - 1:
+ newLine = newLine + '>'
+ if re.match(r'^\s*', newLine):
+ char = '/'
+ elif re.match(r'\\>[^<>]*$', newLine):
+ char = '>'
+ else:
+ char = '\n'
+
+ indentation = self.processChar(newLine, prevLineText, char)
+ newLine = indentation + newLine
+
+ tokens[index] = newLine
+ prevLineText = newLine;
+
+ self._qpart.lines[block.blockNumber()] = '\n'.join(tokens)
+ return None
+ else: # no tokens, do not split line, just compute indent
+ if re.search(r'^\s*', lineText):
+ char = '/'
+ elif re.search(r'>[^<>]*', lineText):
+ char = '>'
+ else:
+ char = '\n'
+
+ return self.processChar(lineText, prevLineText, char)
+
+ def processChar(self, lineText, prevLineText, char):
+ prevIndent = self._lineIndent(prevLineText)
+ if char == '/':
+ if not re.match(r'^\s*', lineText):
+ # might happen when we have something like
+ # don't change indentation then
+ return prevIndent
+
+ if not re.match(r'\s*<[^/][^>]*[^/]>[^<>]*$', prevLineText):
+ # decrease indent when we write and prior line did not start a tag
+ return self._decreaseIndent(prevIndent)
+ elif char == '>':
+ # increase indent width when we write <...> or <.../> but not
+ # and the prior line didn't close a tag
+ if not prevLineText: # first line, zero indent
+ return ''
+ if re.match(r'^<(\?xml|!DOCTYPE).*', prevLineText):
+ return ''
+ elif re.match(r'^<(\?xml|!DOCTYPE).*', lineText):
+ return ''
+ elif re.match('^\s*', lineText):
+ #closing tag, decrease indentation when previous didn't open a tag
+ if re.match(r'\s*<[^/][^>]*[^/]>[^<>]*$', prevLineText):
+ # keep indent when prev line opened a tag
+ return prevIndent;
+ else:
+ return self._decreaseIndent(prevIndent)
+ elif re.search(r'<([/!][^>]+|[^>]+/)>\s*$', prevLineText):
+ # keep indent when prev line closed a tag or was empty or a comment
+ return prevIndent
+
+ return self._increaseIndent(prevIndent)
+ elif char == '\n':
+ if re.match(r'^<(\?xml|!DOCTYPE)', prevLineText):
+ return ''
+ elif re.search(r'<([^/!]|[^/!][^>]*[^/])>[^<>]*$', prevLineText):
+ # increase indent when prev line opened a tag (but not for comments)
+ return self._increaseIndent(prevIndent)
+
+ return prevIndent
diff --git a/Orange/widgets/data/utils/pythoneditor/lines.py b/Orange/widgets/data/utils/pythoneditor/lines.py
new file mode 100644
index 00000000000..dce9bad0b89
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/lines.py
@@ -0,0 +1,187 @@
+"""
+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.
+"""
+"""Lines class.
+list-like object for access text document lines
+"""
+
+from PyQt5.QtGui import QTextCursor
+
+
+def _iterateBlocksFrom(block):
+ while block.isValid():
+ yield block
+ block = block.next()
+
+
+class Lines:
+ """list-like object for access text document lines
+ """
+ def __init__(self, qpart):
+ self._qpart = qpart
+ self._doc = qpart.document()
+
+ def _atomicModification(func):
+ """Decorator
+ Make document modification atomic
+ """
+ def wrapper(*args, **kwargs):
+ self = args[0]
+ with self._qpart:
+ func(*args, **kwargs)
+ return wrapper
+
+ 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/margins.py b/Orange/widgets/data/utils/pythoneditor/margins.py
new file mode 100644
index 00000000000..a3b91ccc99f
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/margins.py
@@ -0,0 +1,201 @@
+"""
+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.
+"""
+"""Base class for margins
+"""
+
+
+from PyQt5.QtCore import QPoint, pyqtSignal
+from PyQt5.QtWidgets import QWidget
+from PyQt5.QtGui import QTextBlock
+
+
+class MarginBase:
+ """Base class which each margin should derive from
+ """
+
+ # The parent class derives from QWidget and mixes MarginBase in at
+ # run-time. Thus the signal declaration and emmitting works here too.
+ blockClicked = pyqtSignal(QTextBlock)
+
+ def __init__(self, parent, name, bit_count):
+ """qpart: reference to the editor
+ name: margin identifier
+ bit_count: number of bits to be used by the margin
+ """
+ self._qpart = parent
+ self._name = name
+ self._bit_count = bit_count
+ self._bitRange = None
+ self.__allocateBits()
+
+ self._countCache = (-1, -1)
+ self._qpart.updateRequest.connect(self.__updateRequest)
+
+ 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._qpart.getMargins()
+
+ occupiedRanges = []
+ for margin in margins:
+ bitRange = margin.getBitRange()
+ if bitRange is not None:
+ # pick the right position
+ added = False
+ for index in range( len( 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._qpart.blockCount() or \
+ self._countCache[1] != self._qpart.textCursor().block().lineCount():
+
+ # if block height not added to rect, last line number sometimes is not drawn
+ blockHeight = self._qpart.blockBoundingRect(self._qpart.firstVisibleBlock()).height()
+
+ self.update(0, rect.y(), self.width(), rect.height() + blockHeight)
+ self._countCache = (self._qpart.blockCount(), self._qpart.textCursor().block().lineCount())
+
+ if rect.contains(self._qpart.viewport().rect()):
+ self._qpart.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():
+ QWidget.hide(self)
+ self._qpart.updateViewport()
+
+ def show(self):
+ """Override the QWidget::show() method to properly recalculate the
+ editor viewport.
+ """
+ if self.isHidden():
+ QWidget.show(self)
+ self._qpart.updateViewport()
+
+ def setVisible(self, val):
+ """Override the QWidget::setVisible(bool) method to properly
+ recalculate the editor viewport.
+ """
+ if val != self.isVisible():
+ if val:
+ QWidget.setVisible(self, True)
+ else:
+ QWidget.setVisible(self, False)
+ self._qpart.updateViewport()
+
+ def mousePressEvent(self, mouseEvent):
+ cursor = self._qpart.cursorForPosition(QPoint(0, mouseEvent.y()))
+ block = cursor.block()
+ blockRect = self._qpart.blockBoundingGeometry(block).translated(self._qpart.contentOffset())
+ if blockRect.bottom() >= mouseEvent.y(): # clicked not lower, then end of text
+ self.blockClicked.emit(block)
+
+ # Convenience methods
+
+ def clear(self):
+ """Convenience method to reset all the block values to 0
+ """
+ if self._bit_count == 0:
+ return
+
+ block = self._qpart.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/rectangularselection.py b/Orange/widgets/data/utils/pythoneditor/rectangularselection.py
new file mode 100644
index 00000000000..7cb48aa4805
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/rectangularselection.py
@@ -0,0 +1,259 @@
+"""
+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()
+
+ 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()
+
+ def isExpandKeyEvent(self, 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 index, char in enumerate(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 i 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 i 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/sideareas.py b/Orange/widgets/data/utils/pythoneditor/sideareas.py
new file mode 100644
index 00000000000..c2302d00c17
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/sideareas.py
@@ -0,0 +1,186 @@
+"""
+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.
+"""
+"""Line numbers and bookmarks areas
+"""
+
+from PyQt5.QtCore import QPoint, Qt, pyqtSignal, QSize
+from PyQt5.QtWidgets import QWidget, QToolTip
+from PyQt5.QtGui import QPainter, QPalette, QPixmap, QTextBlock
+
+import qutepart
+from qutepart.bookmarks import Bookmarks
+from qutepart.margins import MarginBase
+
+
+
+# Dynamic mixin at runtime:
+# http://stackoverflow.com/questions/8544983/dynamically-mixin-a-base-class-to-an-instance-in-python
+def extend_instance(obj, cls):
+ base_cls = obj.__class__
+ base_cls_name = obj.__class__.__name__
+ obj.__class__ = type(base_cls_name, (base_cls, cls), {})
+
+
+
+class LineNumberArea(QWidget):
+ """Line number area widget
+ """
+ _LEFT_MARGIN = 5
+ _RIGHT_MARGIN = 3
+
+ def __init__(self, parent):
+ QWidget.__init__(self, parent)
+
+ extend_instance(self, MarginBase)
+ MarginBase.__init__(self, parent, "line_numbers", 0)
+
+ self.__width = self.__calculateWidth()
+
+ self._qpart.blockCountChanged.connect(self.__updateWidth)
+
+ def __updateWidth(self, newBlockCount=None):
+ newWidth = self.__calculateWidth()
+ if newWidth != self.__width:
+ self.__width = newWidth
+ self._qpart.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._qpart.firstVisibleBlock()
+ blockNumber = block.blockNumber()
+ top = int(self._qpart.blockBoundingGeometry(block).translated(self._qpart.contentOffset()).top())
+ bottom = top + int(self._qpart.blockBoundingRect(block).height())
+ singleBlockHeight = self._qpart.cursorRect().height()
+
+ boundingRect = self._qpart.blockBoundingRect(block)
+ availableWidth = self.__width - self._RIGHT_MARGIN - self._LEFT_MARGIN
+ availableHeight = self._qpart.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._qpart.blockBoundingRect(block)
+ top = bottom
+ bottom = top + int(boundingRect.height())
+ blockNumber += 1
+
+ def __calculateWidth(self):
+ digits = len(str(max(1, self._qpart.blockCount())))
+ return self._LEFT_MARGIN + self._qpart.fontMetrics().width('9') * digits + self._RIGHT_MARGIN
+
+ def width(self):
+ """Desired width. Includes text and margins
+ """
+ return self.__width
+
+ def setFont(self, font):
+ QWidget.setFont(self, font)
+ self.__updateWidth()
+
+
+class MarkArea(QWidget):
+
+ _MARGIN = 1
+
+ def __init__(self, qpart):
+ QWidget.__init__(self, qpart)
+
+ extend_instance(self, MarginBase)
+ MarginBase.__init__(self, qpart, "mark_area", 1)
+
+ qpart.blockCountChanged.connect(self.update)
+
+ self.setMouseTracking(True)
+
+ self._bookmarkPixmap = self._loadIcon('emblem-favorite')
+ self._lintPixmaps = {qpart.LINT_ERROR: self._loadIcon('emblem-error'),
+ qpart.LINT_WARNING: self._loadIcon('emblem-warning'),
+ qpart.LINT_NOTE: self._loadIcon('emblem-information')}
+
+ self._bookmarks = Bookmarks(qpart, self)
+
+ def _loadIcon(self, fileName):
+ icon = qutepart.getIcon(fileName)
+ size = self._qpart.cursorRect().height() - 6
+ pixmap = icon.pixmap(size, size) # This also works with Qt.AA_UseHighDpiPixmaps
+ return pixmap
+
+ def sizeHint(self, ):
+ """QWidget.sizeHint() implementation
+ """
+ return QSize(self.width(), 0)
+
+ def paintEvent(self, event):
+ """QWidget.paintEvent() implementation
+ Draw markers
+ """
+ painter = QPainter(self)
+ painter.fillRect(event.rect(), self.palette().color(QPalette.Window))
+
+ block = self._qpart.firstVisibleBlock()
+ blockBoundingGeometry = self._qpart.blockBoundingGeometry(block).translated(self._qpart.contentOffset())
+ top = blockBoundingGeometry.top()
+ bottom = top + blockBoundingGeometry.height()
+
+ for block in qutepart.iterateBlocksFrom(block):
+ height = self._qpart.blockBoundingGeometry(block).height()
+ if top > event.rect().bottom():
+ break
+ if block.isVisible() and \
+ bottom >= event.rect().top():
+ if block.blockNumber() in self._qpart.lintMarks:
+ msgType, msgText = self._qpart.lintMarks[block.blockNumber()]
+ pixMap = self._lintPixmaps[msgType]
+ yPos = top + ((height - pixMap.height()) / 2) # centered
+ painter.drawPixmap(0, yPos, pixMap)
+
+ if self.isBlockMarked(block):
+ yPos = top + ((height - self._bookmarkPixmap.height()) / 2) # centered
+ painter.drawPixmap(0, yPos, self._bookmarkPixmap)
+
+ top += height
+
+ def width(self):
+ """Desired width. Includes text and margins
+ """
+ return self._MARGIN + self._bookmarkPixmap.width() + self._MARGIN
+
+ def mouseMoveEvent(self, event):
+ blockNumber = self._qpart.cursorForPosition(event.pos()).blockNumber()
+ if blockNumber in self._qpart._lintMarks:
+ msgType, msgText = self._qpart._lintMarks[blockNumber]
+ QToolTip.showText(event.globalPos(), msgText)
+ else:
+ QToolTip.hideText()
+
+ return QWidget.mouseMoveEvent(self, event)
+
+ def clearBookmarks(self, startBlock, endBlock):
+ """Clears the bookmarks
+ """
+ self._bookmarks.clear(startBlock, endBlock)
+
+ def clear(self):
+ self._bookmarks.removeActions()
+ MarginBase.clear(self)
+
diff --git a/Orange/widgets/data/utils/pythoneditor/syntax/__init__.py b/Orange/widgets/data/utils/pythoneditor/syntax/__init__.py
new file mode 100644
index 00000000000..be384b1d4d4
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/syntax/__init__.py
@@ -0,0 +1,269 @@
+"""
+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.
+"""
+"""Source file parser and highlighter
+"""
+
+import os.path
+import fnmatch
+import json
+import threading
+import logging
+import re
+
+_logger = logging.getLogger('qutepart')
+
+class TextFormat:
+ """Text format definition.
+
+ Public attributes:
+ color : Font color, #rrggbb or #rgb
+ background : Font background, #rrggbb or #rgb
+ selectionColor : Color of selected text
+ italic : Italic font, bool
+ bold : Bold font, bool
+ underline : Underlined font, bool
+ strikeOut : Striked out font
+ spellChecking : Text will be spell checked
+ textType : 'c' for comments, 's' for strings, ' ' for other.
+ """
+ def __init__(self, color = '#000000',
+ background = '#ffffff',
+ selectionColor = '#0000ff',
+ italic = False,
+ bold = False,
+ underline = False,
+ strikeOut = False,
+ spellChecking = False):
+
+ self.color = color
+ self.background = background
+ self.selectionColor = selectionColor
+ self.italic = italic
+ self.bold = bold
+ self.underline = underline
+ self.strikeOut = strikeOut
+ self.spellChecking = spellChecking
+ self.textType = ' ' # modified later
+
+ def __cmp__(self, other):
+ return cmp(self.__dict__, other.__dict__)
+
+
+class Syntax:
+ """Syntax. Programming language parser definition
+
+ Public attributes:
+ name Name
+ section Section
+ extensions File extensions
+ mimetype File mime type
+ version XML definition version
+ kateversion Required Kate parser version
+ priority XML definition priority
+ author Author
+ license License
+ hidden Shall be hidden in the menu
+ indenter Indenter for the syntax. Possible values are
+ none, normal, cstyle, haskell, lilypond, lisp, python, ruby, xml
+ None, if not set by xml file
+ """
+ def __init__(self, manager):
+ self.manager = manager
+ self.parser = None
+
+ def __str__(self):
+ res = 'Syntax\n'
+ res += ' name: %s\n' % self.name
+ res += ' section: %s\n' % self.section
+ res += ' extensions: %s\n' % self.extensions
+ res += ' mimetype: %s\n' % self.mimetype
+ res += ' version: %s\n' % self.version
+ res += ' kateversion: %s\n' % self.kateversion
+ res += ' priority: %s\n' % self.priority
+ res += ' author: %s\n' % self.author
+ res += ' license: %s\n' % self.license
+ res += ' hidden: %s\n' % self.hidden
+ res += ' indenter: %s\n' % self.indenter
+ res += str(self.parser)
+
+ return res
+
+ def _setParser(self, parser):
+ self.parser = parser
+ # performance optimization, avoid 1 function call
+ self.highlightBlock = parser.highlightBlock
+ self.parseBlock = parser.parseBlock
+
+ def highlightBlock(self, text, prevLineData):
+ """Parse line of text and return
+ (lineData, highlightedSegments)
+ where
+ lineData is data, which shall be saved and used for parsing next line
+ highlightedSegments is list of touples (segmentLength, segmentFormat)
+ """
+ #self.parser.parseAndPrintBlockTextualResults(text, prevLineData)
+ return self.parser.highlightBlock(text, prevLineData)
+
+ def parseBlock(self, text, prevLineData):
+ """Parse line of text and return
+ lineData
+ where
+ lineData is data, which shall be saved and used for parsing next line
+
+ This is quicker version of highlighBlock, which doesn't return results,
+ but only parsers the block and produces data, which is necessary for parsing next line.
+ Use it for invisible lines
+ """
+ return self.parser.parseBlock(text, prevLineData)
+
+ def _getTextType(self, lineData, column):
+ """Get text type (letter)
+ """
+ if lineData is None:
+ return ' ' # default is code
+
+ textTypeMap = lineData[1]
+ if column >= len(textTypeMap): # probably, not actual data, not updated yet
+ return ' '
+
+ return textTypeMap[column]
+
+ def isCode(self, lineData, column):
+ """Check if text at given position is a code
+ """
+ return self._getTextType(lineData, column) == ' '
+
+ def isComment(self, lineData, column):
+ """Check if text at given position is a comment. Including block comments and here documents
+ """
+ return self._getTextType(lineData, column) in 'cbh'
+
+ def isBlockComment(self, lineData, column):
+ """Check if text at given position is a block comment
+ """
+ return self._getTextType(lineData, column) == 'b'
+
+ def isHereDoc(self, lineData, column):
+ """Check if text at given position is a here document
+ """
+ return self._getTextType(lineData, column) == 'h'
+
+
+class SyntaxManager:
+ """SyntaxManager holds references to loaded Syntax'es and allows to find or
+ load Syntax by its name or by source file name
+ """
+ def __init__(self):
+ self._loadedSyntaxesLock = threading.RLock()
+ self._loadedSyntaxes = {}
+ syntaxDbPath = os.path.join(os.path.abspath(os.path.dirname(__file__)), "data", "syntax_db.json")
+ with open(syntaxDbPath, encoding='utf-8') as syntaxDbFile:
+ syntaxDb = json.load(syntaxDbFile)
+ self._syntaxNameToXmlFileName = syntaxDb['syntaxNameToXmlFileName']
+ self._mimeTypeToXmlFileName = syntaxDb['mimeTypeToXmlFileName']
+ self._firstLineToXmlFileName = syntaxDb['firstLineToXmlFileName']
+ globToXmlFileName = syntaxDb['extensionToXmlFileName']
+
+ # Applying glob patterns is really slow. Therefore they are compiled to reg exps
+ self._extensionToXmlFileName = \
+ {re.compile(fnmatch.translate(glob)): xmlFileName \
+ for glob, xmlFileName in globToXmlFileName.items()}
+
+ def _getSyntaxByXmlFileName(self, xmlFileName):
+ """Get syntax by its xml file name
+ """
+ import qutepart.syntax.loader # delayed import for avoid cross-imports problem
+
+ with self._loadedSyntaxesLock:
+ if not xmlFileName in self._loadedSyntaxes:
+ xmlFilePath = os.path.join(os.path.dirname(__file__), "data", "xml", xmlFileName)
+ syntax = Syntax(self)
+ self._loadedSyntaxes[xmlFileName] = syntax
+ qutepart.syntax.loader.loadSyntax(syntax, xmlFilePath)
+
+ return self._loadedSyntaxes[xmlFileName]
+
+ def _getSyntaxByLanguageName(self, syntaxName):
+ """Get syntax by its name. Name is defined in the xml file
+ """
+ xmlFileName = self._syntaxNameToXmlFileName[syntaxName]
+ return self._getSyntaxByXmlFileName(xmlFileName)
+
+ def _getSyntaxBySourceFileName(self, name):
+ """Get syntax by source name of file, which is going to be highlighted
+ """
+ for regExp, xmlFileName in self._extensionToXmlFileName.items():
+ if regExp.match(name):
+ return self._getSyntaxByXmlFileName(xmlFileName)
+ else:
+ raise KeyError("No syntax for " + name)
+
+ def _getSyntaxByMimeType(self, mimeType):
+ """Get syntax by first line of the file
+ """
+ xmlFileName = self._mimeTypeToXmlFileName[mimeType]
+ return self._getSyntaxByXmlFileName(xmlFileName)
+
+ def _getSyntaxByFirstLine(self, firstLine):
+ """Get syntax by first line of the file
+ """
+ for pattern, xmlFileName in self._firstLineToXmlFileName.items():
+ if fnmatch.fnmatch(firstLine, pattern):
+ return self._getSyntaxByXmlFileName(xmlFileName)
+ else:
+ raise KeyError("No syntax for " + firstLine)
+
+ def getSyntax(self,
+ xmlFileName=None,
+ mimeType=None,
+ languageName=None,
+ sourceFilePath=None,
+ firstLine=None):
+ """Get syntax by one of parameters:
+ * xmlFileName
+ * mimeType
+ * languageName
+ * sourceFilePath
+ First parameter in the list has biggest priority
+ """
+ syntax = None
+
+ if syntax is None and xmlFileName is not None:
+ try:
+ syntax = self._getSyntaxByXmlFileName(xmlFileName)
+ except KeyError:
+ _logger.warning('No xml definition %s' % xmlFileName)
+
+ if syntax is None and mimeType is not None:
+ try:
+ syntax = self._getSyntaxByMimeType(mimeType)
+ except KeyError:
+ _logger.warning('No syntax for mime type %s' % mimeType)
+
+ if syntax is None and languageName is not None:
+ try:
+ syntax = self._getSyntaxByLanguageName(languageName)
+ except KeyError:
+ _logger.warning('No syntax for language %s' % languageName)
+
+ if syntax is None and sourceFilePath is not None:
+ baseName = os.path.basename(sourceFilePath)
+ try:
+ syntax = self._getSyntaxBySourceFileName(baseName)
+ except KeyError:
+ pass
+
+ if syntax is None and firstLine is not None:
+ try:
+ syntax = self._getSyntaxByFirstLine(firstLine)
+ except KeyError:
+ pass
+
+ return syntax
diff --git a/Orange/widgets/data/utils/pythoneditor/syntax/colortheme.py b/Orange/widgets/data/utils/pythoneditor/syntax/colortheme.py
new file mode 100644
index 00000000000..0680c7d8a5f
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/syntax/colortheme.py
@@ -0,0 +1,61 @@
+"""
+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.
+"""
+"""Default color theme
+"""
+
+class ColorTheme:
+ """Color theme.
+ """
+ def __init__(self, textFormatClass):
+ """Constructor gets TextFormat class as parameter for avoid cross-import problems
+ """
+ self.format = {
+ 'dsNormal': textFormatClass(),
+ 'dsKeyword': textFormatClass(bold=True),
+ 'dsFunction': textFormatClass(color='#644a9a'),
+ 'dsVariable': textFormatClass(color='#0057ad'),
+ 'dsControlFlow': textFormatClass(bold=True),
+ 'dsOperator': textFormatClass(),
+ 'dsBuiltIn': textFormatClass(color='#644a9a', bold=True),
+ 'dsExtension': textFormatClass(color='#0094fe', bold=True),
+ 'dsPreprocessor': textFormatClass(color='#006e28'),
+ 'dsAttribute': textFormatClass(color='#0057ad'),
+
+ 'dsChar': textFormatClass(color='#914c9c'),
+ 'dsSpecialChar': textFormatClass(color='#3dade8'),
+ 'dsString': textFormatClass(color='#be0303'),
+ 'dsVerbatimString': textFormatClass(color='#be0303'),
+ 'dsSpecialString': textFormatClass(color='#fe5500'),
+ 'dsImport': textFormatClass(color='#b969c3'),
+
+ 'dsDataType': textFormatClass(color='#0057ad'),
+ 'dsDecVal': textFormatClass(color='#af8000'),
+ 'dsBaseN': textFormatClass(color='#af8000'),
+ 'dsFloat': textFormatClass(color='#af8000'),
+
+ 'dsConstant': textFormatClass(bold=True),
+
+ 'dsComment': textFormatClass(color='#888786'),
+ 'dsDocumentation': textFormatClass(color='#608880'),
+ 'dsAnnotation': textFormatClass(color='#0094fe'),
+ 'dsCommentVar': textFormatClass(color='#c960c9'),
+
+ 'dsRegionMarker': textFormatClass(color='#0057ad', background='#e0e9f8'),
+ 'dsInformation': textFormatClass(color='#af8000'),
+ 'dsWarning': textFormatClass(color='#be0303'),
+ 'dsAlert': textFormatClass(color='#bf0303', background='#f7e6e6', bold=True),
+ 'dsOthers': textFormatClass(color='#006e28'),
+ 'dsError': textFormatClass(color='#bf0303', underline=True),
+ }
+
+ def getFormat(self, styleName):
+ """Returns TextFormat for particular style
+ """
+ return self.format[styleName]
diff --git a/Orange/widgets/data/utils/pythoneditor/syntax/data/regenerate-definitions-db.py b/Orange/widgets/data/utils/pythoneditor/syntax/data/regenerate-definitions-db.py
new file mode 100755
index 00000000000..a949d268403
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/syntax/data/regenerate-definitions-db.py
@@ -0,0 +1,97 @@
+"""
+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.
+"""
+#!/usr/bin/env python3
+
+import os.path
+import json
+
+import sys
+
+_MY_PATH = os.path.abspath(os.path.dirname(__file__))
+sys.path.insert(0, os.path.join(_MY_PATH, '..', '..', '..'))
+
+
+from qutepart.syntax.loader import loadSyntax
+from qutepart.syntax import SyntaxManager, Syntax
+
+
+def _add_php(targetFileName, srcFileName):
+ os.system("./generate-php.pl > xml/{} < xml/{}".format(targetFileName, srcFileName))
+
+
+def main():
+ os.chdir(_MY_PATH)
+ _add_php('javascript-php.xml', 'javascript.xml')
+ _add_php('css-php.xml', 'css.xml')
+ _add_php('html-php.xml', 'html.xml')
+
+ xmlFilesPath = os.path.join(_MY_PATH, 'xml')
+ xmlFileNames = [fileName for fileName in os.listdir(xmlFilesPath) \
+ if fileName.endswith('.xml')]
+
+ syntaxNameToXmlFileName = {}
+ mimeTypeToXmlFileName = {}
+ extensionToXmlFileName = {}
+ firstLineToXmlFileName = {}
+
+ for xmlFileName in xmlFileNames:
+ xmlFilePath = os.path.join(xmlFilesPath, xmlFileName)
+ syntax = Syntax(None)
+ loadSyntax(syntax, xmlFilePath)
+ if not syntax.name in syntaxNameToXmlFileName or \
+ syntaxNameToXmlFileName[syntax.name][0] < syntax.priority:
+ syntaxNameToXmlFileName[syntax.name] = (syntax.priority, xmlFileName)
+
+ if syntax.mimetype:
+ for mimetype in syntax.mimetype:
+ if not mimetype in mimeTypeToXmlFileName or \
+ mimeTypeToXmlFileName[mimetype][0] < syntax.priority:
+ mimeTypeToXmlFileName[mimetype] = (syntax.priority, xmlFileName)
+
+ if syntax.extensions:
+ for extension in syntax.extensions:
+ if extension not in extensionToXmlFileName or \
+ extensionToXmlFileName[extension][0] < syntax.priority:
+ extensionToXmlFileName[extension] = (syntax.priority, xmlFileName)
+
+ if syntax.firstLineGlobs:
+ for glob in syntax.firstLineGlobs:
+ if not glob in firstLineToXmlFileName or \
+ firstLineToXmlFileName[glob][0] < syntax.priority:
+ firstLineToXmlFileName[glob] = (syntax.priority, xmlFileName)
+
+ # remove priority, leave only xml file names
+ for dictionary in (syntaxNameToXmlFileName,
+ mimeTypeToXmlFileName,
+ extensionToXmlFileName,
+ firstLineToXmlFileName):
+ newDictionary = {}
+ for key, item in dictionary.items():
+ newDictionary[key] = item[1]
+ dictionary.clear()
+ dictionary.update(newDictionary)
+
+ # Fix up php first line pattern. It contains %&*/;?[]^{|}~\\"
+
+def _parseBoolAttribute(value):
+ if value.lower() in ('true', '1'):
+ return True
+ elif value.lower() in ('false', '0'):
+ return False
+ else:
+ raise UserWarning("Invalid bool attribute value '%s'" % value)
+
+def _safeGetRequiredAttribute(xmlElement, name, default):
+ if name in xmlElement.attrib:
+ return str(xmlElement.attrib[name])
+ else:
+ _logger.warning("Required attribute '%s' is not set for element '%s'", name, xmlElement.tag)
+ return default
+
+
+def _getContext(contextName, parser, defaultValue):
+ if not contextName:
+ return defaultValue
+ if contextName in parser.contexts:
+ return parser.contexts[contextName]
+ elif contextName.startswith('##') and \
+ parser.syntax.manager is not None: # might be None, if loader is used by regenerate-definitions-db.py
+ syntaxName = contextName[2:]
+ parser = parser.syntax.manager.getSyntax(languageName = syntaxName).parser
+ return parser.defaultContext
+ elif (not contextName.startswith('##')) and \
+ '##' in contextName and \
+ contextName.count('##') == 1 and \
+ parser.syntax.manager is not None: # might be None, if loader is used by regenerate-definitions-db.py
+ name, syntaxName = contextName.split('##')
+ parser = parser.syntax.manager.getSyntax(languageName = syntaxName).parser
+ return parser.contexts[name]
+ else:
+ _logger.warning('Invalid context name %s', repr(contextName))
+ return parser.defaultContext
+
+
+def _makeContextSwitcher(contextOperation, parser):
+ popsCount = 0
+ contextToSwitch = None
+
+ rest = contextOperation
+ while rest.startswith('#pop'):
+ popsCount += 1
+ rest = rest[len('#pop'):]
+ if rest.startswith('!'):
+ rest = rest[1:]
+
+ if rest == '#stay':
+ if popsCount:
+ _logger.warning("Invalid context operation '%s'", contextOperation)
+ else:
+ contextToSwitch = _getContext(rest, parser, None)
+
+ if popsCount > 0 or contextToSwitch != None:
+ return _parserModule.ContextSwitcher(popsCount, contextToSwitch, contextOperation)
+ else:
+ return None
+
+
+################################################################################
+## Rules
+################################################################################
+
+def _loadIncludeRules(parentContext, xmlElement, attributeToFormatMap):
+ contextName = _safeGetRequiredAttribute(xmlElement, "context", None)
+
+ context = _getContext(contextName, parentContext.parser, parentContext.parser.defaultContext)
+
+ abstractRuleParams = _loadAbstractRuleParams(parentContext,
+ xmlElement,
+ attributeToFormatMap)
+ return _parserModule.IncludeRules(abstractRuleParams, context)
+
+def _simpleLoader(classObject):
+ def _load(parentContext, xmlElement, attributeToFormatMap):
+ abstractRuleParams = _loadAbstractRuleParams(parentContext,
+ xmlElement,
+ attributeToFormatMap)
+ return classObject(abstractRuleParams)
+ return _load
+
+def _loadChildRules(context, xmlElement, attributeToFormatMap):
+ """Extract rules from Context or Rule xml element
+ """
+ rules = []
+ for ruleElement in xmlElement.getchildren():
+ if not ruleElement.tag in _ruleClassDict:
+ raise ValueError("Not supported rule '%s'" % ruleElement.tag)
+ rule = _ruleClassDict[ruleElement.tag](context, ruleElement, attributeToFormatMap)
+ rules.append(rule)
+ return rules
+
+def _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap):
+ # attribute
+ attribute = xmlElement.attrib.get("attribute", None)
+ if attribute is not None:
+ attribute = attribute.lower() # not case sensitive
+ try:
+ format = attributeToFormatMap[attribute]
+ textType = format.textType if format is not None else ' '
+ if format is not None:
+ format = _convertFormat(format)
+ except KeyError:
+ _logger.warning('Unknown rule attribute %s', attribute)
+ format = parentContext.format
+ textType = parentContext.textType
+ else:
+ format = None
+ textType = None
+
+ # context
+ contextText = xmlElement.attrib.get("context", '#stay')
+ context = _makeContextSwitcher(contextText, parentContext.parser)
+
+ lookAhead = _parseBoolAttribute(xmlElement.attrib.get("lookAhead", "false"))
+ firstNonSpace = _parseBoolAttribute(xmlElement.attrib.get("firstNonSpace", "false"))
+ dynamic = _parseBoolAttribute(xmlElement.attrib.get("dynamic", "false"))
+
+ # TODO beginRegion
+ # TODO endRegion
+
+ column = xmlElement.attrib.get("column", None)
+ if column is not None:
+ column = int(column)
+ else:
+ column = -1
+
+ return _parserModule.AbstractRuleParams(parentContext, format, textType, attribute, context, lookAhead, firstNonSpace, dynamic, column)
+
+def _loadDetectChar(parentContext, xmlElement, attributeToFormatMap):
+ abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
+
+ char = _safeGetRequiredAttribute(xmlElement, "char", None)
+ if char is not None:
+ char = _processEscapeSequences(char)
+
+ index = 0
+ if abstractRuleParams.dynamic:
+ try:
+ index = int(char)
+ except ValueError:
+ _logger.warning('Invalid DetectChar char %s', char)
+ index = 0
+ char = None
+ if index <= 0:
+ _logger.warning('Too little DetectChar index %d', index)
+ index = 0
+
+ return _parserModule.DetectChar(abstractRuleParams, str(char), index)
+
+def _loadDetect2Chars(parentContext, xmlElement, attributeToFormatMap):
+ char = _safeGetRequiredAttribute(xmlElement, 'char', None)
+ char1 = _safeGetRequiredAttribute(xmlElement, 'char1', None)
+ if char is None or char1 is None:
+ string = None
+ else:
+ string = _processEscapeSequences(char) + _processEscapeSequences(char1)
+
+ abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
+ return _parserModule.Detect2Chars(abstractRuleParams, string)
+
+def _loadAnyChar(parentContext, xmlElement, attributeToFormatMap):
+ string = _safeGetRequiredAttribute(xmlElement, 'String', '')
+ abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
+ return _parserModule.AnyChar(abstractRuleParams, string)
+
+def _loadStringDetect(parentContext, xmlElement, attributeToFormatMap):
+ string = _safeGetRequiredAttribute(xmlElement, 'String', None)
+
+ abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
+ return _parserModule.StringDetect(abstractRuleParams,
+ string)
+
+def _loadWordDetect(parentContext, xmlElement, attributeToFormatMap):
+ word = _safeGetRequiredAttribute(xmlElement, "String", "")
+ insensitive = _parseBoolAttribute(xmlElement.attrib.get("insensitive", "false"))
+
+ abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
+
+ return _parserModule.WordDetect(abstractRuleParams, word, insensitive)
+
+def _loadKeyword(parentContext, xmlElement, attributeToFormatMap):
+ string = _safeGetRequiredAttribute(xmlElement, 'String', None)
+ try:
+ words = parentContext.parser.lists[string]
+ except KeyError:
+ _logger.warning("List '%s' not found", string)
+
+ words = list()
+
+ insensitive = _parseBoolAttribute(xmlElement.attrib.get("insensitive", "false"))
+
+ abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
+ return _parserModule.keyword(abstractRuleParams, words, insensitive)
+
+def _loadRegExpr(parentContext, xmlElement, attributeToFormatMap):
+ def _processCraracterCodes(text):
+ """QRegExp use \0ddd notation for character codes, where d in octal digit
+ i.e. \0377 is character with code 255 in the unicode table
+ Convert such notation to unicode text
+ """
+ text = str(text)
+ def replFunc(matchObj):
+ matchText = matchObj.group(0)
+ charCode = eval('0o' + matchText[2:])
+ return chr(charCode)
+ return re.sub(r"\\0\d\d\d", replFunc, text)
+
+ insensitive = _parseBoolAttribute(xmlElement.attrib.get('insensitive', 'false'))
+ minimal = _parseBoolAttribute(xmlElement.attrib.get('minimal', 'false'))
+ string = _safeGetRequiredAttribute(xmlElement, 'String', None)
+
+ if string is not None:
+ string = _processCraracterCodes(string)
+
+ strippedString = string.strip('(')
+
+ wordStart = strippedString.startswith('\\b')
+ lineStart = strippedString.startswith('^')
+ if len(strippedString) > 1 and strippedString[1] == '|': # ^|blabla This condition is not ideal but will cover majority of cases
+ wordStart = False
+ lineStart = False
+ else:
+ wordStart = False
+ lineStart = False
+
+ abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
+ return _parserModule.RegExpr(abstractRuleParams,
+ string, insensitive, minimal, wordStart, lineStart)
+
+def _loadAbstractNumberRule(rule, parentContext, xmlElement):
+ abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
+ return _parserModule.NumberRule(abstractRuleParams, childRules)
+
+def _loadInt(parentContext, xmlElement, attributeToFormatMap):
+ childRules = _loadChildRules(parentContext, xmlElement, attributeToFormatMap)
+ abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
+ return _parserModule.Int(abstractRuleParams, childRules)
+
+def _loadFloat(parentContext, xmlElement, attributeToFormatMap):
+ childRules = _loadChildRules(parentContext, xmlElement, attributeToFormatMap)
+ abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
+ return _parserModule.Float(abstractRuleParams, childRules)
+
+def _loadRangeDetect(parentContext, xmlElement, attributeToFormatMap):
+ char = _safeGetRequiredAttribute(xmlElement, "char", 'char is not set')
+ char1 = _safeGetRequiredAttribute(xmlElement, "char1", 'char1 is not set')
+
+ abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
+ return _parserModule.RangeDetect(abstractRuleParams, char, char1)
+
+
+_ruleClassDict = \
+{
+ 'DetectChar': _loadDetectChar,
+ 'Detect2Chars': _loadDetect2Chars,
+ 'AnyChar': _loadAnyChar,
+ 'StringDetect': _loadStringDetect,
+ 'WordDetect': _loadWordDetect,
+ 'RegExpr': _loadRegExpr,
+ 'keyword': _loadKeyword,
+ 'Int': _loadInt,
+ 'Float': _loadFloat,
+ 'HlCOct': _simpleLoader(_parserModule.HlCOct),
+ 'HlCHex': _simpleLoader(_parserModule.HlCHex),
+ 'HlCStringChar': _simpleLoader(_parserModule.HlCStringChar),
+ 'HlCChar': _simpleLoader(_parserModule.HlCChar),
+ 'RangeDetect': _loadRangeDetect,
+ 'LineContinue': _simpleLoader(_parserModule.LineContinue),
+ 'IncludeRules': _loadIncludeRules,
+ 'DetectSpaces': _simpleLoader(_parserModule.DetectSpaces),
+ 'DetectIdentifier': _simpleLoader(_parserModule.DetectIdentifier)
+}
+
+################################################################################
+## Context
+################################################################################
+
+
+def _loadContexts(highlightingElement, parser, attributeToFormatMap):
+ contextsElement = highlightingElement.find('contexts')
+
+ xmlElementList = contextsElement.findall('context')
+ contextList = []
+ for xmlElement in xmlElementList:
+ name = _safeGetRequiredAttribute(xmlElement,
+ 'name',
+ 'Error: context name is not set!!!')
+ context = _parserModule.Context(parser, name)
+ contextList.append(context)
+
+ defaultContext = contextList[0]
+
+ contextDict = {}
+ for context in contextList:
+ contextDict[context.name] = context
+
+ parser.setContexts(contextDict, defaultContext)
+
+ # parse contexts stage 2: load contexts
+ for xmlElement, context in zip(xmlElementList, contextList):
+ _loadContext(context, xmlElement, attributeToFormatMap)
+
+
+def _loadContext(context, xmlElement, attributeToFormatMap):
+ """Construct context from XML element
+ Contexts are at first constructed, and only then loaded, because when loading context,
+ _makeContextSwitcher must have references to all defined contexts
+ """
+ attribute = _safeGetRequiredAttribute(xmlElement, 'attribute', '').lower()
+ if attribute != '': # there are no attributes for internal contexts, used by rules. See perl.xml
+ try:
+ format = attributeToFormatMap[attribute]
+ except KeyError:
+ _logger.warning('Unknown context attribute %s', attribute)
+ format = TextFormat()
+ else:
+ format = None
+
+ textType = format.textType if format is not None else ' '
+ if format is not None:
+ format = _convertFormat(format)
+
+ lineEndContextText = xmlElement.attrib.get('lineEndContext', '#stay')
+ lineEndContext = _makeContextSwitcher(lineEndContextText, context.parser)
+ lineBeginContextText = xmlElement.attrib.get('lineBeginContext', '#stay')
+ lineBeginContext = _makeContextSwitcher(lineBeginContextText, context.parser)
+ lineEmptyContextText = xmlElement.attrib.get('lineEmptyContext', '#stay')
+ lineEmptyContext = _makeContextSwitcher(lineEmptyContextText, context.parser)
+
+ if _parseBoolAttribute(xmlElement.attrib.get('fallthrough', 'false')):
+ fallthroughContextText = _safeGetRequiredAttribute(xmlElement, 'fallthroughContext', '#stay')
+ fallthroughContext = _makeContextSwitcher(fallthroughContextText, context.parser)
+ else:
+ fallthroughContext = None
+
+ dynamic = _parseBoolAttribute(xmlElement.attrib.get('dynamic', 'false'))
+
+ context.setValues(attribute, format, lineEndContext, lineBeginContext, lineEmptyContext, fallthroughContext, dynamic, textType)
+
+ # load rules
+ rules = _loadChildRules(context, xmlElement, attributeToFormatMap)
+ context.setRules(rules)
+
+################################################################################
+## Syntax
+################################################################################
+def _textTypeForDefStyleName(attribute, defStyleName):
+ """ ' ' for code
+ 'c' for comments
+ 'b' for block comments
+ 'h' for here documents
+ """
+ if 'here' in attribute.lower() and defStyleName == 'dsOthers':
+ return 'h' # ruby
+ elif 'block' in attribute.lower() and defStyleName == 'dsComment':
+ return 'b'
+ elif defStyleName in ('dsString', 'dsRegionMarker', 'dsChar', 'dsOthers'):
+ return 's'
+ elif defStyleName == 'dsComment':
+ return 'c'
+ else:
+ return ' '
+
+def _makeFormat(defaultTheme, defaultStyleName, textType, item=None):
+ format = copy.copy(defaultTheme.format[defaultStyleName])
+
+ format.textType = textType
+
+ if item is not None:
+ caseInsensitiveAttributes = {}
+ for key, value in item.attrib.items():
+ caseInsensitiveAttributes[key.lower()] = value.lower()
+
+ if 'color' in caseInsensitiveAttributes:
+ format.color = caseInsensitiveAttributes['color']
+ if 'selColor' in caseInsensitiveAttributes:
+ format.selectionColor = caseInsensitiveAttributes['selColor']
+ if 'italic' in caseInsensitiveAttributes:
+ format.italic = _parseBoolAttribute(caseInsensitiveAttributes['italic'])
+ if 'bold' in caseInsensitiveAttributes:
+ format.bold = _parseBoolAttribute(caseInsensitiveAttributes['bold'])
+ if 'underline' in caseInsensitiveAttributes:
+ format.underline = _parseBoolAttribute(caseInsensitiveAttributes['underline'])
+ if 'strikeout' in caseInsensitiveAttributes:
+ format.strikeOut = _parseBoolAttribute(caseInsensitiveAttributes['strikeout'])
+ if 'spellChecking' in caseInsensitiveAttributes:
+ format.spellChecking = _parseBoolAttribute(caseInsensitiveAttributes['spellChecking'])
+
+ return format
+
+def _loadAttributeToFormatMap(highlightingElement):
+ defaultTheme = ColorTheme(TextFormat)
+ attributeToFormatMap = {}
+
+ itemDatasElement = highlightingElement.find('itemDatas')
+ if itemDatasElement is not None:
+ for item in itemDatasElement.findall('itemData'):
+ attribute = item.get('name').lower()
+ defaultStyleName = item.get('defStyleNum')
+
+ if not defaultStyleName in defaultTheme.format:
+ _logger.warning("Unknown default style '%s'", defaultStyleName)
+ defaultStyleName = 'dsNormal'
+
+ format = _makeFormat(defaultTheme,
+ defaultStyleName,
+ _textTypeForDefStyleName(attribute, defaultStyleName),
+ item)
+
+ attributeToFormatMap[attribute] = format
+
+ # HACK not documented, but 'normal' attribute is used by some parsers without declaration
+ if not 'normal' in attributeToFormatMap:
+ attributeToFormatMap['normal'] = _makeFormat(defaultTheme, 'dsNormal',
+ _textTypeForDefStyleName('normal', 'dsNormal'))
+ if not 'string' in attributeToFormatMap:
+ attributeToFormatMap['string'] = _makeFormat(defaultTheme, 'dsString',
+ _textTypeForDefStyleName('string', 'dsString'))
+
+ return attributeToFormatMap
+
+def _loadLists(root, highlightingElement):
+ lists = {} # list name: list
+ for listElement in highlightingElement.findall('list'):
+ # Sometimes item.text is none. Broken xml files
+ items = [str(item.text.strip()) \
+ for item in listElement.findall('item') \
+ if item.text is not None]
+ name = _safeGetRequiredAttribute(listElement, 'name', 'Error: list name is not set!!!')
+ lists[name] = items
+
+ return lists
+
+def _makeKeywordsLowerCase(listDict):
+ # Make all keywords lowercase, if syntax is not case sensitive
+ for keywordList in listDict.values():
+ for index, keyword in enumerate(keywordList):
+ keywordList[index] = keyword.lower()
+
+def _loadSyntaxDescription(root, syntax):
+ syntax.name = _safeGetRequiredAttribute(root, 'name', 'Error: .parser name is not set!!!')
+ syntax.section = _safeGetRequiredAttribute(root, 'section', 'Error: Section is not set!!!')
+ syntax.extensions = [_f for _f in _safeGetRequiredAttribute(root, 'extensions', '').split(';') if _f]
+ syntax.firstLineGlobs = [_f for _f in root.attrib.get('firstLineGlobs', '').split(';') if _f]
+ syntax.mimetype = [_f for _f in root.attrib.get('mimetype', '').split(';') if _f]
+ syntax.version = root.attrib.get('version', None)
+ syntax.kateversion = root.attrib.get('kateversion', None)
+ syntax.priority = int(root.attrib.get('priority', '0'))
+ syntax.author = root.attrib.get('author', None)
+ syntax.license = root.attrib.get('license', None)
+ syntax.hidden = _parseBoolAttribute(root.attrib.get('hidden', 'false'))
+
+ # not documented
+ syntax.indenter = root.attrib.get('indenter', None)
+
+
+def loadSyntax(syntax, filePath = None):
+ _logger.debug("Loading syntax %s", filePath)
+ with open(filePath, 'r', encoding='utf-8') as definitionFile:
+ try:
+ root = xml.etree.ElementTree.parse(definitionFile).getroot()
+ except Exception as ex:
+ print('When opening %s:' % filePath, file=sys.stderr)
+ raise
+
+ highlightingElement = root.find('highlighting')
+
+ _loadSyntaxDescription(root, syntax)
+
+ deliminatorSet = set(_DEFAULT_DELIMINATOR)
+
+ # parse lists
+ lists = _loadLists(root, highlightingElement)
+
+ # parse itemData
+ keywordsCaseSensitive = True
+
+ generalElement = root.find('general')
+ if generalElement is not None:
+ keywordsElement = generalElement.find('keywords')
+
+ if keywordsElement is not None:
+ keywordsCaseSensitive = _parseBoolAttribute(keywordsElement.get('casesensitive', "true"))
+
+ if not keywordsCaseSensitive:
+ _makeKeywordsLowerCase(lists)
+
+ if 'weakDeliminator' in keywordsElement.attrib:
+ weakSet = keywordsElement.attrib['weakDeliminator']
+ deliminatorSet.difference_update(weakSet)
+
+ if 'additionalDeliminator' in keywordsElement.attrib:
+ additionalSet = keywordsElement.attrib['additionalDeliminator']
+ deliminatorSet.update(additionalSet)
+
+ indentationElement = generalElement.find('indentation')
+
+ if indentationElement is not None and \
+ 'mode' in indentationElement.attrib:
+ syntax.indenter = indentationElement.attrib['mode']
+
+ deliminatorSetAsString = ''.join(list(deliminatorSet))
+ debugOutputEnabled = _logger.isEnabledFor(logging.DEBUG) # for cParser
+ parser = _parserModule.Parser(syntax, deliminatorSetAsString, lists, keywordsCaseSensitive, debugOutputEnabled)
+ syntax._setParser(parser)
+ attributeToFormatMap = _loadAttributeToFormatMap(highlightingElement)
+
+ # parse contexts
+ _loadContexts(highlightingElement, syntax.parser, attributeToFormatMap)
+
+ return syntax
diff --git a/Orange/widgets/data/utils/pythoneditor/syntax/parser.py b/Orange/widgets/data/utils/pythoneditor/syntax/parser.py
new file mode 100644
index 00000000000..3a7becb4803
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/syntax/parser.py
@@ -0,0 +1,1003 @@
+"""
+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.
+"""
+"""Kate syntax definition parser and representation
+
+Do not use this module directly. Use 'syntax' module
+
+Read http://kate-editor.org/2005/03/24/writing-a-syntax-highlighting-file/
+if you want to understand something
+
+
+'attribute' property of rules and contexts contains not an original string,
+but value from itemDatas section (style name)
+
+'context', 'lineBeginContext', 'lineEndContext', 'fallthroughContext' properties
+contain not a text value, but ContextSwitcher object
+"""
+
+import re
+import logging
+
+_logger = logging.getLogger('qutepart')
+
+_numSeqReplacer = re.compile('%\d+')
+
+
+class ContextStack:
+ def __init__(self, contexts, data):
+ """Create default context stack for syntax
+ Contains default context on the top
+ """
+ self._contexts = contexts
+ self._data = data
+
+ def pop(self, count):
+ """Returns new context stack, which doesn't contain few levels
+ """
+ if len(self._contexts) - 1 < count:
+ _logger.error("#pop value is too big %d", len(self._contexts))
+ if len(self._contexts) > 1:
+ return ContextStack(self._contexts[:1], self._data[:1])
+ else:
+ return self
+
+ return ContextStack(self._contexts[:-count], self._data[:-count])
+
+ def append(self, context, data):
+ """Returns new context, which contains current stack and new frame
+ """
+ return ContextStack(self._contexts + [context], self._data + [data])
+
+ def currentContext(self):
+ """Get current context
+ """
+ return self._contexts[-1]
+
+ def currentData(self):
+ """Get current data
+ """
+ return self._data[-1]
+
+
+class ContextSwitcher:
+ """Class parses 'context', 'lineBeginContext', 'lineEndContext', 'fallthroughContext'
+ and modifies context stack according to context operation
+ """
+ def __init__(self, popsCount, contextToSwitch, contextOperation):
+ self._popsCount = popsCount
+ self._contextToSwitch = contextToSwitch
+ self._contextOperation = contextOperation
+
+ def __str__(self):
+ return self._contextOperation
+
+ def getNextContextStack(self, contextStack, data=None):
+ """Apply modification to the contextStack.
+ This method never modifies input parameter list
+ """
+ if self._popsCount:
+ contextStack = contextStack.pop(self._popsCount)
+
+ if self._contextToSwitch is not None:
+ if not self._contextToSwitch.dynamic:
+ data = None
+ contextStack = contextStack.append(self._contextToSwitch, data)
+
+ return contextStack
+
+
+class TextToMatchObject:
+ """Peace of text, which shall be matched.
+ Contains pre-calculated and pre-checked data for performance optimization
+ """
+ def __init__(self, currentColumnIndex, wholeLineText, deliminatorSet, contextData):
+ self.currentColumnIndex = currentColumnIndex
+ self.wholeLineText = wholeLineText
+ self.text = wholeLineText[currentColumnIndex:]
+ self.textLen = len(self.text)
+
+ self.firstNonSpace = not bool(wholeLineText[:currentColumnIndex].strip())
+
+ self.isWordStart = currentColumnIndex == 0 or \
+ wholeLineText[currentColumnIndex - 1].isspace() or \
+ wholeLineText[currentColumnIndex - 1] in deliminatorSet
+
+ self.word = None
+ if self.isWordStart:
+ wordEndIndex = 0
+ for index, char in enumerate(self.text):
+ if char in deliminatorSet:
+ wordEndIndex = index
+ break
+ else:
+ wordEndIndex = len(wholeLineText)
+
+ if wordEndIndex != 0:
+ self.word = self.text[:wordEndIndex]
+
+ self.contextData = contextData
+
+
+class RuleTryMatchResult:
+ def __init__(self, rule, length, data=None):
+ self.rule = rule
+ self.length = length
+ self.data = data
+
+ if rule.lookAhead:
+ self.length = 0
+
+
+class AbstractRuleParams:
+ """Parameters, passed to the AbstractRule constructor
+ """
+ def __init__(self, parentContext, format, textType, attribute, context, lookAhead, firstNonSpace, dynamic, column):
+ self.parentContext = parentContext
+ self.format = format
+ self.textType = textType
+ self.attribute = attribute
+ self.context = context
+ self.lookAhead = lookAhead
+ self.firstNonSpace = firstNonSpace
+ self.dynamic = dynamic
+ self.column = column
+
+
+class AbstractRule:
+ """Base class for rule classes
+ Public attributes:
+ parentContext
+ format May be None
+ textType May be None
+ attribute May be None
+ context
+ lookAhead
+ firstNonSpace
+ column -1 if not set
+ dynamic
+ """
+
+ _seqReplacer = re.compile('%\d+')
+
+ def __init__(self, params):
+ self.parentContext = params.parentContext
+ self.format = params.format
+ self.textType = params.textType
+ self.attribute = params.attribute
+ self.context = params.context
+ self.lookAhead = params.lookAhead
+ self.firstNonSpace = params.firstNonSpace
+ self.dynamic = params.dynamic
+ self.column = params.column
+
+ def __str__(self):
+ """Serialize.
+ For debug logs
+ """
+ res = '\t\tRule %s\n' % self.shortId()
+ res += '\t\t\tstyleName: %s\n' % (self.attribute or 'None')
+ res += '\t\t\tcontext: %s\n' % self.context
+ return res
+
+ def shortId(self):
+ """Get short ID string of the rule. Used for logs
+ i.e. "DetectChar(x)"
+ """
+ raise NotImplementedError(str(self.__class__))
+
+ def tryMatch(self, textToMatchObject):
+ """Try to find themselves in the text.
+ Returns (contextStack, count, matchedRule) or (contextStack, None, None) if doesn't match
+ """
+ # Skip if column doesn't match
+ if self.column != -1 and \
+ self.column != textToMatchObject.currentColumnIndex:
+ return None
+
+ if self.firstNonSpace and \
+ (not textToMatchObject.firstNonSpace):
+ return None
+
+ return self._tryMatch(textToMatchObject)
+
+
+class DetectChar(AbstractRule):
+ """Public attributes:
+ char
+ """
+ def __init__(self, abstractRuleParams, char, index):
+ AbstractRule.__init__(self, abstractRuleParams)
+ self.char = char
+ self.index = index
+
+ def shortId(self):
+ return 'DetectChar(%s, %d)' % (self.char, self.index)
+
+ def _tryMatch(self, textToMatchObject):
+ if self.char is None and self.index == 0:
+ return None
+
+ if self.dynamic:
+ index = self.index - 1
+ if index >= len(textToMatchObject.contextData):
+ _logger.error('Invalid DetectChar index %d', index)
+ return None
+
+ if len(textToMatchObject.contextData[index]) != 1:
+ _logger.error('Too long DetectChar string %s', textToMatchObject.contextData[index])
+ return None
+
+ string = textToMatchObject.contextData[index]
+ else:
+ string = self.char
+
+ if textToMatchObject.text[0] == string:
+ return RuleTryMatchResult(self, 1)
+ return None
+
+
+class Detect2Chars(AbstractRule):
+ """Public attributes
+ string
+ """
+ def __init__(self, abstractRuleParams, string):
+ AbstractRule.__init__(self, abstractRuleParams)
+ self.string = string
+
+ def shortId(self):
+ return 'Detect2Chars(%s)' % self.string
+
+ def _tryMatch(self, textToMatchObject):
+ if self.string is None:
+ return None
+
+ if textToMatchObject.text.startswith(self.string):
+ return RuleTryMatchResult(self, len(self.string))
+
+ return None
+
+
+class AnyChar(AbstractRule):
+ """Public attributes:
+ string
+ """
+ def __init__(self, abstractRuleParams, string):
+ AbstractRule.__init__(self, abstractRuleParams)
+ self.string = string
+
+ def shortId(self):
+ return 'AnyChar(%s)' % self.string
+
+ def _tryMatch(self, textToMatchObject):
+ if textToMatchObject.text[0] in self.string:
+ return RuleTryMatchResult(self, 1)
+
+ return None
+
+
+class StringDetect(AbstractRule):
+ """Public attributes:
+ string
+ """
+ def __init__(self, abstractRuleParams, string):
+ AbstractRule.__init__(self, abstractRuleParams)
+ self.string = string
+
+ def shortId(self):
+ return 'StringDetect(%s)' % self.string
+
+ def _tryMatch(self, textToMatchObject):
+ if self.string is None:
+ return None
+
+ if self.dynamic:
+ string = self._makeDynamicSubsctitutions(self.string, textToMatchObject.contextData)
+ if not string:
+ return None
+ else:
+ string = self.string
+
+ if textToMatchObject.text.startswith(string):
+ return RuleTryMatchResult(self, len(string))
+
+ return None
+
+ @staticmethod
+ def _makeDynamicSubsctitutions(string, contextData):
+ """For dynamic rules, replace %d patterns with actual strings
+ Python function, which is used by C extension.
+ """
+ def _replaceFunc(escapeMatchObject):
+ stringIndex = escapeMatchObject.group(0)[1]
+ index = int(stringIndex)
+ if index < len(contextData):
+ return contextData[index]
+ else:
+ return escapeMatchObject.group(0) # no any replacements, return original value
+
+ return _numSeqReplacer.sub(_replaceFunc, string)
+
+
+class WordDetect(AbstractRule):
+ """Public attributes:
+ words
+ """
+ def __init__(self, abstractRuleParams, word, insensitive):
+ AbstractRule.__init__(self, abstractRuleParams)
+ self.word = word
+ self.insensitive = insensitive
+
+ def shortId(self):
+ return 'WordDetect(%s, %d)' % (self.word, self.insensitive)
+
+ def _tryMatch(self, textToMatchObject):
+ if textToMatchObject.word is None:
+ return None
+
+ if self.insensitive or \
+ (not self.parentContext.parser.keywordsCaseSensitive):
+ wordToCheck = textToMatchObject.word.lower()
+ else:
+ wordToCheck = textToMatchObject.word
+
+ if wordToCheck == self.word:
+ return RuleTryMatchResult(self, len(wordToCheck))
+ else:
+ return None
+
+
+class keyword(AbstractRule):
+ """Public attributes:
+ string
+ words
+ """
+ def __init__(self, abstractRuleParams, words, insensitive):
+ AbstractRule.__init__(self, abstractRuleParams)
+ self.words = set(words)
+ self.insensitive = insensitive
+
+ def shortId(self):
+ return 'keyword(%s, %d)' % (' '.join(list(self.words)), self.insensitive)
+
+ def _tryMatch(self, textToMatchObject):
+ if textToMatchObject.word is None:
+ return None
+
+ if self.insensitive or \
+ (not self.parentContext.parser.keywordsCaseSensitive):
+ wordToCheck = textToMatchObject.word.lower()
+ else:
+ wordToCheck = textToMatchObject.word
+
+ if wordToCheck in self.words:
+ return RuleTryMatchResult(self, len(wordToCheck))
+ else:
+ return None
+
+
+class RegExpr(AbstractRule):
+ """ Public attributes:
+ regExp
+ wordStart
+ lineStart
+ """
+ def __init__(self, abstractRuleParams,
+ string, insensitive, minimal, wordStart, lineStart):
+ AbstractRule.__init__(self, abstractRuleParams)
+ self.string = string
+ self.insensitive = insensitive
+ self.minimal = minimal
+ self.wordStart = wordStart
+ self.lineStart = lineStart
+
+ if self.dynamic:
+ self.regExp = None
+ else:
+ self.regExp = self._compileRegExp(string, insensitive, minimal)
+
+
+ def shortId(self):
+ return 'RegExpr( %s )' % self.string
+
+ def _tryMatch(self, textToMatchObject):
+ """Tries to parse text. If matched - saves data for dynamic context
+ """
+ # Special case. if pattern starts with \b, we have to check it manually,
+ # because string is passed to .match(..) without beginning
+ if self.wordStart and \
+ (not textToMatchObject.isWordStart):
+ return None
+
+ #Special case. If pattern starts with ^ - check column number manually
+ if self.lineStart and \
+ textToMatchObject.currentColumnIndex > 0:
+ return None
+
+ if self.dynamic:
+ string = self._makeDynamicSubsctitutions(self.string, textToMatchObject.contextData)
+ regExp = self._compileRegExp(string, self.insensitive, self.minimal)
+ else:
+ regExp = self.regExp
+
+ if regExp is None:
+ return None
+
+ wholeMatch, groups = self._matchPattern(regExp, textToMatchObject.text)
+ if wholeMatch is not None:
+ count = len(wholeMatch)
+ return RuleTryMatchResult(self, count, groups)
+ else:
+ return None
+
+ @staticmethod
+ def _makeDynamicSubsctitutions(string, contextData):
+ """For dynamic rules, replace %d patterns with actual strings
+ Escapes reg exp symbols in the pattern
+ Python function, used by C code
+ """
+ def _replaceFunc(escapeMatchObject):
+ stringIndex = escapeMatchObject.group(0)[1]
+ index = int(stringIndex)
+ if index < len(contextData):
+ return re.escape(contextData[index])
+ else:
+ return escapeMatchObject.group(0) # no any replacements, return original value
+
+ return _numSeqReplacer.sub(_replaceFunc, string)
+
+ @staticmethod
+ def _compileRegExp(string, insensitive, minimal):
+ """Compile regular expression.
+ Python function, used by C code
+
+ NOTE minimal flag is not supported here, but supported on PCRE
+ """
+ flags = 0
+ if insensitive:
+ flags = re.IGNORECASE
+
+ string = string.replace('[_[:alnum:]]', '[\\w\\d]') # ad-hoc fix for C++ parser
+ string = string.replace('[:digit:]', '\\d')
+ string = string.replace('[:blank:]', '\\s')
+
+ try:
+ return re.compile(string, flags)
+ except (re.error, AssertionError) as ex:
+ _logger.warning("Invalid pattern '%s': %s", string, str(ex))
+ return None
+
+ @staticmethod
+ def _matchPattern(regExp, string):
+ """Try to match pattern.
+ Returns tuple (whole match, groups) or (None, None)
+ Python function, used by C code
+ """
+ match = regExp.match(string)
+ if match is not None and match.group(0):
+ return match.group(0), (match.group(0), ) + match.groups()
+ else:
+ return None, None
+
+
+class AbstractNumberRule(AbstractRule):
+ """Base class for Int and Float rules.
+ This rules can have child rules
+
+ Public attributes:
+ childRules
+ """
+ def __init__(self, abstractRuleParams, childRules):
+ AbstractRule.__init__(self, abstractRuleParams)
+ self.childRules = childRules
+
+ def _tryMatch(self, textToMatchObject):
+ """Try to find themselves in the text.
+ Returns (count, matchedRule) or (None, None) if doesn't match
+ """
+
+ # andreikop: This check is not described in kate docs, and I haven't found it in the code
+ if not textToMatchObject.isWordStart:
+ return None
+
+ index = self._tryMatchText(textToMatchObject.text)
+ if index is None:
+ return None
+
+ if textToMatchObject.currentColumnIndex + index < len(textToMatchObject.wholeLineText):
+ newTextToMatchObject = TextToMatchObject(textToMatchObject.currentColumnIndex + index,
+ textToMatchObject.wholeLineText,
+ self.parentContext.parser.deliminatorSet,
+ textToMatchObject.contextData)
+ for rule in self.childRules:
+ ruleTryMatchResult = rule.tryMatch(newTextToMatchObject)
+ if ruleTryMatchResult is not None:
+ index += ruleTryMatchResult.length
+ break
+ # child rule context and attribute ignored
+
+ return RuleTryMatchResult(self, index)
+
+ def _countDigits(self, text):
+ """Count digits at start of text
+ """
+ index = 0
+ while index < len(text):
+ if not text[index].isdigit():
+ break
+ index += 1
+ return index
+
+
+class Int(AbstractNumberRule):
+ def shortId(self):
+ return 'Int()'
+
+ def _tryMatchText(self, text):
+ matchedLength = self._countDigits(text)
+
+ if matchedLength:
+ return matchedLength
+ else:
+ return None
+
+
+class Float(AbstractNumberRule):
+ def shortId(self):
+ return 'Float()'
+
+ def _tryMatchText(self, text):
+
+ haveDigit = False
+ havePoint = False
+
+ matchedLength = 0
+
+ digitCount = self._countDigits(text[matchedLength:])
+ if digitCount:
+ haveDigit = True
+ matchedLength += digitCount
+
+ if len(text) > matchedLength and text[matchedLength] == '.':
+ havePoint = True
+ matchedLength += 1
+
+ digitCount = self._countDigits(text[matchedLength:])
+ if digitCount:
+ haveDigit = True
+ matchedLength += digitCount
+
+ if len(text) > matchedLength and text[matchedLength].lower() == 'e':
+ matchedLength += 1
+
+ if len(text) > matchedLength and text[matchedLength] in '+-':
+ matchedLength += 1
+
+ haveDigitInExponent = False
+
+ digitCount = self._countDigits(text[matchedLength:])
+ if digitCount:
+ haveDigitInExponent = True
+ matchedLength += digitCount
+
+ if not haveDigitInExponent:
+ return None
+
+ return matchedLength
+ else:
+ if not havePoint:
+ return None
+
+ if matchedLength and haveDigit:
+ return matchedLength
+ else:
+ return None
+
+
+class HlCOct(AbstractRule):
+ def shortId(self):
+ return 'HlCOct'
+
+ def _tryMatch(self, textToMatchObject):
+ if textToMatchObject.text[0] != '0':
+ return None
+
+ index = 1
+ while index < len(textToMatchObject.text) and textToMatchObject.text[index] in '01234567':
+ index += 1
+
+ if index == 1:
+ return None
+
+ if index < len(textToMatchObject.text) and textToMatchObject.text[index].upper() in 'LU':
+ index += 1
+
+ return RuleTryMatchResult(self, index)
+
+
+class HlCHex(AbstractRule):
+ def shortId(self):
+ return 'HlCHex'
+
+ def _tryMatch(self, textToMatchObject):
+ if len(textToMatchObject.text) < 3:
+ return None
+
+ if textToMatchObject.text[:2].upper() != '0X':
+ return None
+
+ index = 2
+ while index < len(textToMatchObject.text) and textToMatchObject.text[index].upper() in '0123456789ABCDEF':
+ index += 1
+
+ if index == 2:
+ return None
+
+ if index < len(textToMatchObject.text) and textToMatchObject.text[index].upper() in 'LU':
+ index += 1
+
+ return RuleTryMatchResult(self, index)
+
+
+def _checkEscapedChar(text):
+ index = 0
+ if len(text) > 1 and text[0] == '\\':
+ index = 1
+
+ if text[index] in "abefnrtv'\"?\\":
+ index += 1
+ elif text[index] == 'x': # if it's like \xff, eat the x
+ index += 1
+ while index < len(text) and text[index].upper() in '0123456789ABCDEF':
+ index += 1
+ if index == 2: # no hex digits
+ return None
+ elif text[index] in '01234567':
+ while index < 4 and index < len(text) and text[index] in '01234567':
+ index += 1
+ else:
+ return None
+
+ return index
+
+ return None
+
+
+class HlCStringChar(AbstractRule):
+ def shortId(self):
+ return 'HlCStringChar'
+
+ def _tryMatch(self, textToMatchObject):
+ res = _checkEscapedChar(textToMatchObject.text)
+ if res is not None:
+ return RuleTryMatchResult(self, res)
+ else:
+ return None
+
+
+class HlCChar(AbstractRule):
+ def shortId(self):
+ return 'HlCChar'
+
+ def _tryMatch(self, textToMatchObject):
+ if len(textToMatchObject.text) > 2 and textToMatchObject.text[0] == "'" and textToMatchObject.text[1] != "'":
+ result = _checkEscapedChar(textToMatchObject.text[1:])
+ if result is not None:
+ index = 1 + result
+ else: # 1 not escaped character
+ index = 1 + 1
+
+ if index < len(textToMatchObject.text) and textToMatchObject.text[index] == "'":
+ return RuleTryMatchResult(self, index + 1)
+
+ return None
+
+
+class RangeDetect(AbstractRule):
+ """Public attributes:
+ char
+ char1
+ """
+ def __init__(self, abstractRuleParams, char, char1):
+ AbstractRule.__init__(self, abstractRuleParams)
+ self.char = char
+ self.char1 = char1
+
+ def shortId(self):
+ return 'RangeDetect(%s, %s)' % (self.char, self.char1)
+
+ def _tryMatch(self, textToMatchObject):
+ if textToMatchObject.text.startswith(self.char):
+ end = textToMatchObject.text.find(self.char1, 1)
+ if end > 0:
+ return RuleTryMatchResult(self, end + 1)
+
+ return None
+
+
+class LineContinue(AbstractRule):
+ def shortId(self):
+ return 'LineContinue'
+
+ def _tryMatch(self, textToMatchObject):
+ if textToMatchObject.text == '\\':
+ return RuleTryMatchResult(self, 1)
+
+ return None
+
+
+class IncludeRules(AbstractRule):
+ def __init__(self, abstractRuleParams, context):
+ AbstractRule.__init__(self, abstractRuleParams)
+ self.context = context
+
+ def __str__(self):
+ """Serialize.
+ For debug logs
+ """
+ res = '\t\tRule %s\n' % self.shortId()
+ res += '\t\t\tstyleName: %s\n' % (self.attribute or 'None')
+ return res
+
+ def shortId(self):
+ return "IncludeRules(%s)" % self.context.name
+
+ def _tryMatch(self, textToMatchObject):
+ """Try to find themselves in the text.
+ Returns (count, matchedRule) or (None, None) if doesn't match
+ """
+ for rule in self.context.rules:
+ ruleTryMatchResult = rule.tryMatch(textToMatchObject)
+ if ruleTryMatchResult is not None:
+ _logger.debug('\tmatched rule %s at %d in included context %s/%s',
+ rule.shortId(),
+ textToMatchObject.currentColumnIndex,
+ self.context.parser.syntax.name,
+ self.context.name)
+ return ruleTryMatchResult
+ else:
+ return None
+
+
+class DetectSpaces(AbstractRule):
+ def shortId(self):
+ return 'DetectSpaces()'
+
+ def _tryMatch(self, textToMatchObject):
+ spaceLen = len(textToMatchObject.text) - len(textToMatchObject.text.lstrip())
+ if spaceLen:
+ return RuleTryMatchResult(self, spaceLen)
+ else:
+ return None
+
+
+class DetectIdentifier(AbstractRule):
+ _regExp = re.compile('[a-zA-Z][a-zA-Z0-9_]*')
+ def shortId(self):
+ return 'DetectIdentifier()'
+
+ def _tryMatch(self, textToMatchObject):
+ match = DetectIdentifier._regExp.match(textToMatchObject.text)
+ if match is not None and match.group(0):
+ return RuleTryMatchResult(self, len(match.group(0)))
+
+ return None
+
+
+class Context:
+ """Highlighting context
+
+ Public attributes:
+ attribute
+ lineEndContext
+ lineBeginContext
+ fallthroughContext
+ dynamic
+ rules
+ textType ' ' : code, 'c' : comment
+ """
+ def __init__(self, parser, name):
+ # Will be initialized later, after all context has been created
+ self.parser = parser
+ self.name = name
+
+ def setValues(self, attribute, format, lineEndContext, lineBeginContext, lineEmptyContext, fallthroughContext, dynamic, textType):
+ self.attribute = attribute
+ self.format = format
+ self.lineEndContext = lineEndContext
+ self.lineBeginContext = lineBeginContext
+ self.lineEmptyContext = lineEmptyContext
+ self.fallthroughContext = fallthroughContext
+ self.dynamic = dynamic
+ self.textType = textType
+
+ def setRules(self, rules):
+ self.rules = rules
+
+ def __str__(self):
+ """Serialize.
+ For debug logs
+ """
+ res = '\tContext %s\n' % self.name
+ res += '\t\t%s: %s\n' % ('attribute', self.attribute)
+ res += '\t\t%s: %s\n' % ('lineEndContext', self.lineEndContext)
+ res += '\t\t%s: %s\n' % ('lineBeginContext', self.lineBeginContext)
+ res += '\t\t%s: %s\n' % ('lineEmptyContext', self.lineEmptyContext)
+ if self.fallthroughContext is not None:
+ res += '\t\t%s: %s\n' % ('fallthroughContext', self.fallthroughContext)
+ res += '\t\t%s: %s\n' % ('dynamic', self.dynamic)
+
+ for rule in self.rules:
+ res += str(rule)
+ return res
+
+ def parseBlock(self, contextStack, currentColumnIndex, text):
+ """Parse block
+ Exits, when reached end of the text, or when context is switched
+ Returns (length, newContextStack, highlightedSegments, lineContinue)
+ """
+ startColumnIndex = currentColumnIndex
+ countOfNotMatchedSymbols = 0
+ highlightedSegments = []
+ textTypeMap = []
+ ruleTryMatchResult = None
+ while currentColumnIndex < len(text):
+ textToMatchObject = TextToMatchObject(currentColumnIndex,
+ text,
+ self.parser.deliminatorSet,
+ contextStack.currentData())
+ for rule in self.rules:
+ ruleTryMatchResult = rule.tryMatch(textToMatchObject)
+ if ruleTryMatchResult is not None: # if something matched
+ _logger.debug('\tmatched rule %s at %d',
+ rule.shortId(),
+ currentColumnIndex)
+ if countOfNotMatchedSymbols > 0:
+ highlightedSegments.append((countOfNotMatchedSymbols, self.format))
+ textTypeMap += [self.textType for i in range(countOfNotMatchedSymbols)]
+ countOfNotMatchedSymbols = 0
+
+ if ruleTryMatchResult.rule.context is not None:
+ newContextStack = ruleTryMatchResult.rule.context.getNextContextStack(contextStack,
+ ruleTryMatchResult.data)
+ else:
+ newContextStack = contextStack
+
+ format = ruleTryMatchResult.rule.format if ruleTryMatchResult.rule.attribute else newContextStack.currentContext().format
+ textType = ruleTryMatchResult.rule.textType or newContextStack.currentContext().textType
+
+ highlightedSegments.append((ruleTryMatchResult.length,
+ format))
+ textTypeMap += textType * ruleTryMatchResult.length
+
+ currentColumnIndex += ruleTryMatchResult.length
+
+ if newContextStack != contextStack:
+ lineContinue = isinstance(ruleTryMatchResult.rule, LineContinue)
+
+ return currentColumnIndex - startColumnIndex, newContextStack, highlightedSegments, textTypeMap, lineContinue
+
+ break # for loop
+ else: # no matched rules
+ if self.fallthroughContext is not None:
+ newContextStack = self.fallthroughContext.getNextContextStack(contextStack)
+ if newContextStack != contextStack:
+ if countOfNotMatchedSymbols > 0:
+ highlightedSegments.append((countOfNotMatchedSymbols, self.format))
+ textTypeMap += [self.textType for i in range(countOfNotMatchedSymbols)]
+ return (currentColumnIndex - startColumnIndex, newContextStack, highlightedSegments, textTypeMap, False)
+
+ currentColumnIndex += 1
+ countOfNotMatchedSymbols += 1
+
+ if countOfNotMatchedSymbols > 0:
+ highlightedSegments.append((countOfNotMatchedSymbols, self.format))
+ textTypeMap += [self.textType for i in range(countOfNotMatchedSymbols)]
+
+ lineContinue = ruleTryMatchResult is not None and \
+ isinstance(ruleTryMatchResult.rule, LineContinue)
+
+ return currentColumnIndex - startColumnIndex, contextStack, highlightedSegments, textTypeMap, lineContinue
+
+
+class Parser:
+ """Parser implementation
+
+ syntax Syntax instance
+
+ attributeToFormatMap Map "attribute" : TextFormat
+
+ deliminatorSet Set of deliminator characters
+ lists Keyword lists as dictionary "list name" : "list value"
+ keywordsCaseSensitive If true, keywords are not case sensitive
+
+ contexts Context list as dictionary "context name" : context
+ defaultContext Default context object
+ """
+ def __init__(self, syntax, deliminatorSetAsString, lists, keywordsCaseSensitive, debugOutputEnabled):
+ self.syntax = syntax
+ self.deliminatorSet = set(deliminatorSetAsString)
+ self.lists = lists
+ self.keywordsCaseSensitive = keywordsCaseSensitive
+ # debugOutputEnabled is used only by cParser
+
+ def setContexts(self, contexts, defaultContext):
+ self.contexts = contexts
+ self.defaultContext = defaultContext
+ self._defaultContextStack = ContextStack([self.defaultContext], [None])
+
+ def __str__(self):
+ """Serialize.
+ For debug logs
+ """
+ res = 'Parser\n'
+ for name, value in vars(self).items():
+ if not name.startswith('_') and \
+ not name in ('defaultContext', 'deliminatorSet', 'contexts', 'lists', 'syntax') and \
+ not value is None:
+ res += '\t%s: %s\n' % (name, value)
+
+ res += '\tDefault context: %s\n' % self.defaultContext.name
+
+ for listName, listValue in self.lists.items():
+ res += '\tList %s: %s\n' % (listName, listValue)
+
+
+ for context in self.contexts.values():
+ res += str(context)
+
+ return res
+
+ def highlightBlock(self, text, prevContextStack):
+ """Parse block and return ParseBlockFullResult
+
+ return (lineData, highlightedSegments)
+ where lineData is (contextStack, textTypeMap)
+ where textTypeMap is a string of textType characters
+ """
+ if prevContextStack is not None:
+ contextStack = prevContextStack
+ else:
+ contextStack = self._defaultContextStack
+
+ highlightedSegments = []
+ lineContinue = False
+ currentColumnIndex = 0
+ textTypeMap = []
+
+ if len(text) > 0:
+ while currentColumnIndex < len(text):
+ _logger.debug('In context %s', contextStack.currentContext().name)
+
+ length, newContextStack, segments, textTypeMapPart, lineContinue = \
+ contextStack.currentContext().parseBlock(contextStack, currentColumnIndex, text)
+
+ highlightedSegments += segments
+ contextStack = newContextStack
+ textTypeMap += textTypeMapPart
+ currentColumnIndex += length
+
+ if not lineContinue:
+ while contextStack.currentContext().lineEndContext is not None:
+ oldStack = contextStack
+ contextStack = contextStack.currentContext().lineEndContext.getNextContextStack(contextStack)
+ if oldStack == contextStack: # avoid infinite while loop if nothing to switch
+ break
+
+ # this code is not tested, because lineBeginContext is not defined by any xml file
+ if contextStack.currentContext().lineBeginContext is not None:
+ contextStack = contextStack.currentContext().lineBeginContext.getNextContextStack(contextStack)
+ elif contextStack.currentContext().lineEmptyContext is not None:
+ contextStack = contextStack.currentContext().lineEmptyContext.getNextContextStack(contextStack)
+
+ lineData = (contextStack, textTypeMap)
+ return lineData, highlightedSegments
+
+ def parseBlock(self, text, prevContextStack):
+ return self.highlightBlock(text, prevContextStack)[0]
diff --git a/Orange/widgets/data/utils/pythoneditor/syntaxhlighter.py b/Orange/widgets/data/utils/pythoneditor/syntaxhlighter.py
new file mode 100644
index 00000000000..9502a592bb6
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/syntaxhlighter.py
@@ -0,0 +1,304 @@
+"""
+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.
+"""
+"""QSyntaxHighlighter implementation
+Uses syntax module for doing the job
+"""
+
+import time
+
+from PyQt5.QtCore import QObject, QTimer, pyqtSlot
+from PyQt5.QtWidgets import QApplication
+from PyQt5.QtGui import QTextBlockUserData, QTextLayout
+
+import qutepart.syntax
+
+
+def _cmpFormatRanges(a, b):
+ """PyQt does not define proper comparison for QTextLayout.FormatRange
+ Define it to check correctly, if formats has changed.
+ It is important for the performance
+ """
+ if a.format == b.format and \
+ a.start == b.start and \
+ a.length == b.length:
+ return 0
+ else:
+ return cmp(id(a), id(b))
+
+
+def _formatRangeListsEqual(a, b):
+ if len(a) != len(b):
+ return False
+
+ for a_item, b_item in zip(a, b):
+ if a_item != b_item:
+ return False
+
+ return True
+
+
+class _TextBlockUserData(QTextBlockUserData):
+ def __init__(self, data):
+ QTextBlockUserData.__init__(self)
+ self.data = data
+
+
+class GlobalTimer:
+ """All parsing and highlighting is done in main loop thread.
+ If parsing is being done for long time, main loop gets blocked.
+ Therefore SyntaxHighlighter controls, how long parsign is going, and, if too long,
+ schedules timer and releases main loop.
+ One global timer is used by all Qutepart instances, because main loop time usage
+ must not depend on opened files count
+ """
+
+ def __init__(self):
+ self._timer = QTimer(QApplication.instance())
+ self._timer.setSingleShot(True)
+ self._timer.timeout.connect(self._onTimer)
+
+ self._scheduledCallbacks = []
+
+ def isActive(self):
+ return self._timer.isActive()
+
+ def scheduleCallback(self, callback):
+ if not callback in self._scheduledCallbacks:
+ self._scheduledCallbacks.append(callback)
+ self._timer.start()
+
+ def unScheduleCallback(self, callback):
+ if callback in self._scheduledCallbacks:
+ self._scheduledCallbacks.remove(callback)
+
+ if not self._scheduledCallbacks:
+ self._timer.stop()
+
+ def isCallbackScheduled(self, callback):
+ return callback in self._scheduledCallbacks
+
+ def _onTimer(self):
+ if self._scheduledCallbacks:
+ callback = self._scheduledCallbacks.pop()
+ callback()
+ if self._scheduledCallbacks:
+ self._timer.start()
+
+
+"""Global var, because main loop time usage shall not depend on Qutepart instances count
+
+Pyside crashes, if this variable is a class field
+"""
+_gLastChangeTime = -777.
+
+
+class SyntaxHighlighter(QObject):
+
+ # when initially parsing text, it is better, if highlighted text is drawn without flickering
+ _MAX_PARSING_TIME_BIG_CHANGE_SEC = 0.4
+ # when user is typing text - response shall be quick
+ _MAX_PARSING_TIME_SMALL_CHANGE_SEC = 0.02
+
+ _globalTimer = GlobalTimer()
+
+ def __init__(self, syntax, textEdit):
+ QObject.__init__(self, textEdit.document())
+
+ self._syntax = syntax
+ self._textEdit = textEdit
+ self._document = textEdit.document()
+
+ # can't store references to block, Qt crashes if block removed
+ self._pendingBlockNumber = None
+ self._pendingAtLeastUntilBlockNumber = None
+
+ self._document.contentsChange.connect(self._onContentsChange)
+
+ charsAdded = self._document.lastBlock().position() + self._document.lastBlock().length()
+ self._onContentsChange(0, 0, charsAdded, zeroTimeout=self._wasChangedJustBefore())
+
+ def terminate(self):
+ try:
+ self._document.contentsChange.disconnect(self._onContentsChange)
+ except TypeError:
+ pass
+
+ self._globalTimer.unScheduleCallback(self._onContinueHighlighting)
+ block = self._document.firstBlock()
+ while block.isValid():
+ block.layout().setAdditionalFormats([])
+ block.setUserData(None)
+ self._document.markContentsDirty(block.position(), block.length())
+ block = block.next()
+ self._globalTimer.unScheduleCallback(self._onContinueHighlighting)
+
+ def syntax(self):
+ """Return own syntax
+ """
+ return self._syntax
+
+ def isInProgress(self):
+ """Highlighting is in progress
+ """
+ return self._globalTimer.isCallbackScheduled(self._onContinueHighlighting)
+
+ def isCode(self, block, column):
+ """Check if character at column is a a code
+ """
+ dataObject = block.userData()
+ data = dataObject.data if dataObject is not None else None
+ return self._syntax.isCode(data, column)
+
+ def isComment(self, block, column):
+ """Check if character at column is a comment
+ """
+ dataObject = block.userData()
+ data = dataObject.data if dataObject is not None else None
+ return self._syntax.isComment(data, column)
+
+ def isBlockComment(self, block, column):
+ """Check if character at column is a block comment
+ """
+ dataObject = block.userData()
+ data = dataObject.data if dataObject is not None else None
+ return self._syntax.isBlockComment(data, column)
+
+ def isHereDoc(self, block, column):
+ """Check if character at column is a here document
+ """
+ dataObject = block.userData()
+ data = dataObject.data if dataObject is not None else None
+ return self._syntax.isHereDoc(data, column)
+
+ @staticmethod
+ def _lineData(block):
+ dataObject = block.userData()
+ if dataObject is not None:
+ return dataObject.data
+ else:
+ return None
+
+ def _wasChangedJustBefore(self):
+ """Check if ANY Qutepart instance was changed just before"""
+ return time.time() <= _gLastChangeTime + 1
+
+ @pyqtSlot(int, int, int)
+ def _onContentsChange(self, from_, charsRemoved, charsAdded, zeroTimeout=False):
+ global _gLastChangeTime
+ firstBlock = self._document.findBlock(from_)
+ untilBlock = self._document.findBlock(from_ + charsAdded)
+
+ if self._globalTimer.isCallbackScheduled(self._onContinueHighlighting): # have not finished task.
+ """ Intersect ranges. Might produce a lot of extra highlighting work
+ More complicated algorithm might be invented later
+ """
+ if self._pendingBlockNumber < firstBlock.blockNumber():
+ firstBlock = self._document.findBlockByNumber(self._pendingBlockNumber)
+ if self._pendingAtLeastUntilBlockNumber > untilBlock.blockNumber():
+ untilBlockNumber = min(self._pendingAtLeastUntilBlockNumber,
+ self._document.blockCount() - 1)
+ untilBlock = self._document.findBlockByNumber(untilBlockNumber)
+ self._globalTimer.unScheduleCallback(self._onContinueHighlighting)
+
+ if zeroTimeout:
+ timeout = 0 # no parsing, only schedule
+ elif charsAdded > 20 and \
+ (not self._wasChangedJustBefore()):
+ """Use big timeout, if change is really big and previous big change was long time ago"""
+ timeout = self._MAX_PARSING_TIME_BIG_CHANGE_SEC
+ else:
+ timeout = self._MAX_PARSING_TIME_SMALL_CHANGE_SEC
+
+ _gLastChangeTime = time.time()
+
+ self._highlighBlocks(firstBlock, untilBlock, timeout)
+
+ def _onContinueHighlighting(self):
+ self._highlighBlocks(self._document.findBlockByNumber(self._pendingBlockNumber),
+ self._document.findBlockByNumber(self._pendingAtLeastUntilBlockNumber),
+ self._MAX_PARSING_TIME_SMALL_CHANGE_SEC)
+
+ def _highlighBlocks(self, fromBlock, atLeastUntilBlock, timeout):
+ endTime = time.time() + timeout
+
+ block = fromBlock
+ lineData = self._lineData(block.previous())
+
+ while block.isValid() and block != atLeastUntilBlock:
+ if time.time() >= endTime: # time is over, schedule parsing later and release event loop
+ self._pendingBlockNumber = block.blockNumber()
+ self._pendingAtLeastUntilBlockNumber = atLeastUntilBlock.blockNumber()
+ self._globalTimer.scheduleCallback(self._onContinueHighlighting)
+ return
+
+ contextStack = lineData[0] if lineData is not None else None
+ if block.length() < 4096:
+ lineData, highlightedSegments = self._syntax.highlightBlock(block.text(), contextStack)
+ else:
+ """Parser freezes for a long time, if line is too long
+ invalid parsing results are still better, than freeze
+ """
+ lineData, highlightedSegments = None, []
+ if lineData is not None:
+ block.setUserData(_TextBlockUserData(lineData))
+ else:
+ block.setUserData(None)
+
+ self._applyHighlightedSegments(block, highlightedSegments)
+ block = block.next()
+
+ # reached atLeastUntilBlock, now parse next only while data changed
+ prevLineData = self._lineData(block)
+ while block.isValid():
+ if time.time() >= endTime: # time is over, schedule parsing later and release event loop
+ self._pendingBlockNumber = block.blockNumber()
+ self._pendingAtLeastUntilBlockNumber = atLeastUntilBlock.blockNumber()
+ self._globalTimer.scheduleCallback(self._onContinueHighlighting)
+ return
+ contextStack = lineData[0] if lineData is not None else None
+ lineData, highlightedSegments = self._syntax.highlightBlock(block.text(), contextStack)
+ if lineData is not None:
+ block.setUserData(_TextBlockUserData(lineData))
+ else:
+ block.setUserData(None)
+
+ self._applyHighlightedSegments(block, highlightedSegments)
+ if prevLineData == lineData:
+ break
+
+ block = block.next()
+ prevLineData = self._lineData(block)
+
+ # sucessfully finished, reset pending tasks
+ self._pendingBlockNumber = None
+ self._pendingAtLeastUntilBlockNumber = None
+
+ """Emit sizeChanged when highlighting finished, because document size might change.
+ See andreikop/enki issue #191
+ """
+ documentLayout = self._textEdit.document().documentLayout()
+ documentLayout.documentSizeChanged.emit(documentLayout.documentSize())
+
+ def _applyHighlightedSegments(self, block, highlightedSegments):
+ ranges = []
+ currentPos = 0
+
+ for length, format in highlightedSegments:
+ if format is not None: # might be in incorrect syntax file
+ range = QTextLayout.FormatRange()
+ range.format = format
+ range.start = currentPos
+ range.length = length
+ ranges.append(range)
+ currentPos += length
+
+ if not _formatRangeListsEqual(block.layout().additionalFormats(), ranges):
+ block.layout().setAdditionalFormats(ranges)
+ self._document.markContentsDirty(block.position(), block.length())
diff --git a/Orange/widgets/data/utils/pythoneditor/version.py b/Orange/widgets/data/utils/pythoneditor/version.py
new file mode 100644
index 00000000000..440a726dd3a
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/version.py
@@ -0,0 +1,10 @@
+"""
+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.
+"""
+VERSION = (3, 3, 1)
diff --git a/Orange/widgets/data/utils/pythoneditor/vim.py b/Orange/widgets/data/utils/pythoneditor/vim.py
new file mode 100644
index 00000000000..04d29e43d6c
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/vim.py
@@ -0,0 +1,1279 @@
+"""
+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
+
+
+""" This magic code sets variables like _a and _A in the global scope
+"""
+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:
+ 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:
+ self._qpart.cursorPosition = (line, col - 1) # return the cursor back after replacement
+ 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
+ cursor.movePosition(QTextCursor.NextCharacter, moveMode) # move to the next line
+
+ # 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)
+
+ def _iterateDocumentCharsForward(self, 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()
+
+ def _iterateDocumentCharsBackward(self, 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, startCol), (endLine, endCol) = 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()
+ cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) # expand until the end of line
+ 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,
+ }
From 6b774e5b60fa385e3a3fcfd6ac415857780db517 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Sun, 24 Jan 2021 00:08:14 +0000
Subject: [PATCH 06/44] qutepart: Adjust for Orange
---
.../data/utils/pythoneditor/bookmarks.py | 96 -
...ackethlighter.py => brackethighlighter.py} | 42 +-
.../data/utils/pythoneditor/completer.py | 891 +++----
.../widgets/data/utils/pythoneditor/editor.py | 2046 +++++++++--------
.../data/utils/pythoneditor/htmldelegate.py | 93 -
.../data/utils/pythoneditor/indenter.py | 530 +++++
.../utils/pythoneditor/indenter/__init__.py | 242 --
.../data/utils/pythoneditor/indenter/base.py | 297 ---
.../utils/pythoneditor/indenter/cstyle.py | 644 ------
.../data/utils/pythoneditor/indenter/lisp.py | 35 -
.../utils/pythoneditor/indenter/python.py | 107 -
.../data/utils/pythoneditor/indenter/ruby.py | 297 ---
.../utils/pythoneditor/indenter/scheme.py | 79 -
.../utils/pythoneditor/indenter/xmlindent.py | 105 -
.../widgets/data/utils/pythoneditor/lines.py | 42 +-
.../data/utils/pythoneditor/margins.py | 201 --
.../pythoneditor/rectangularselection.py | 30 +-
.../data/utils/pythoneditor/sideareas.py | 186 --
.../utils/pythoneditor/syntax/__init__.py | 269 ---
.../utils/pythoneditor/syntax/colortheme.py | 61 -
.../syntax/data/regenerate-definitions-db.py | 97 -
.../data/utils/pythoneditor/syntax/loader.py | 640 ------
.../data/utils/pythoneditor/syntax/parser.py | 1003 --------
.../data/utils/pythoneditor/syntaxhlighter.py | 304 ---
.../data/utils/pythoneditor/tests/base.py | 79 +
.../data/utils/pythoneditor/tests/run_all.py | 27 +
.../data/utils/pythoneditor/tests/test_api.py | 281 +++
.../tests/test_bracket_highlighter.py | 71 +
.../tests/test_draw_whitespace.py | 102 +
.../utils/pythoneditor/tests/test_edit.py | 111 +
.../utils/pythoneditor/tests/test_indent.py | 140 ++
.../test_indenter/__init__.py} | 1 -
.../tests/test_indenter/indenttest.py | 68 +
.../tests/test_indenter/test_python.py | 342 +++
.../tests/test_rectangular_selection.py | 259 +++
.../data/utils/pythoneditor/tests/test_vim.py | 1041 +++++++++
Orange/widgets/data/utils/pythoneditor/vim.py | 138 +-
37 files changed, 4798 insertions(+), 6199 deletions(-)
delete mode 100644 Orange/widgets/data/utils/pythoneditor/bookmarks.py
rename Orange/widgets/data/utils/pythoneditor/{brackethlighter.py => brackethighlighter.py} (84%)
delete mode 100644 Orange/widgets/data/utils/pythoneditor/htmldelegate.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/indenter.py
delete mode 100644 Orange/widgets/data/utils/pythoneditor/indenter/__init__.py
delete mode 100644 Orange/widgets/data/utils/pythoneditor/indenter/base.py
delete mode 100644 Orange/widgets/data/utils/pythoneditor/indenter/cstyle.py
delete mode 100644 Orange/widgets/data/utils/pythoneditor/indenter/lisp.py
delete mode 100644 Orange/widgets/data/utils/pythoneditor/indenter/python.py
delete mode 100644 Orange/widgets/data/utils/pythoneditor/indenter/ruby.py
delete mode 100644 Orange/widgets/data/utils/pythoneditor/indenter/scheme.py
delete mode 100644 Orange/widgets/data/utils/pythoneditor/indenter/xmlindent.py
delete mode 100644 Orange/widgets/data/utils/pythoneditor/margins.py
delete mode 100644 Orange/widgets/data/utils/pythoneditor/sideareas.py
delete mode 100644 Orange/widgets/data/utils/pythoneditor/syntax/__init__.py
delete mode 100644 Orange/widgets/data/utils/pythoneditor/syntax/colortheme.py
delete mode 100755 Orange/widgets/data/utils/pythoneditor/syntax/data/regenerate-definitions-db.py
delete mode 100644 Orange/widgets/data/utils/pythoneditor/syntax/loader.py
delete mode 100644 Orange/widgets/data/utils/pythoneditor/syntax/parser.py
delete mode 100644 Orange/widgets/data/utils/pythoneditor/syntaxhlighter.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/tests/base.py
create mode 100644 Orange/widgets/data/utils/pythoneditor/tests/run_all.py
create mode 100755 Orange/widgets/data/utils/pythoneditor/tests/test_api.py
create mode 100755 Orange/widgets/data/utils/pythoneditor/tests/test_bracket_highlighter.py
create mode 100755 Orange/widgets/data/utils/pythoneditor/tests/test_draw_whitespace.py
create mode 100755 Orange/widgets/data/utils/pythoneditor/tests/test_edit.py
create mode 100755 Orange/widgets/data/utils/pythoneditor/tests/test_indent.py
rename Orange/widgets/data/utils/pythoneditor/{version.py => tests/test_indenter/__init__.py} (94%)
create mode 100644 Orange/widgets/data/utils/pythoneditor/tests/test_indenter/indenttest.py
create mode 100755 Orange/widgets/data/utils/pythoneditor/tests/test_indenter/test_python.py
create mode 100755 Orange/widgets/data/utils/pythoneditor/tests/test_rectangular_selection.py
create mode 100755 Orange/widgets/data/utils/pythoneditor/tests/test_vim.py
diff --git a/Orange/widgets/data/utils/pythoneditor/bookmarks.py b/Orange/widgets/data/utils/pythoneditor/bookmarks.py
deleted file mode 100644
index eaf3ecc2fc2..00000000000
--- a/Orange/widgets/data/utils/pythoneditor/bookmarks.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""
-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.
-"""
-"""Bookmarks functionality implementation"""
-
-from PyQt5.QtCore import Qt
-from PyQt5.QtWidgets import QAction
-from PyQt5.QtGui import QKeySequence, QTextCursor
-
-import qutepart
-
-
-class Bookmarks:
- """Bookmarks functionality implementation, grouped in one class
- """
- def __init__(self, qpart, markArea):
- self._qpart = qpart
- self._markArea = markArea
- qpart.toggleBookmarkAction = self._createAction(qpart, "emblem-favorite", "Toogle bookmark", 'Ctrl+B',
- self._onToggleBookmark)
- qpart.prevBookmarkAction = self._createAction(qpart, "go-up", "Previous bookmark", 'Alt+PgUp',
- self._onPrevBookmark)
- qpart.nextBookmarkAction = self._createAction(qpart, "go-down", "Next bookmark", 'Alt+PgDown',
- self._onNextBookmark)
-
- markArea.blockClicked.connect(self._toggleBookmark)
-
- def _createAction(self, widget, iconFileName, text, shortcut, slot):
- """Create QAction with given parameters and add to the widget
- """
- icon = qutepart.getIcon(iconFileName)
- action = QAction(icon, text, widget)
- action.setShortcut(QKeySequence(shortcut))
- action.setShortcutContext(Qt.WidgetShortcut)
- action.triggered.connect(slot)
-
- widget.addAction(action)
-
- return action
-
- def removeActions(self):
- self._qpart.removeAction(self._qpart.toggleBookmarkAction)
- self._qpart.toggleBookmarkAction = None
- self._qpart.removeAction(self._qpart.prevBookmarkAction)
- self._qpart.prevBookmarkAction = None
- self._qpart.removeAction(self._qpart.nextBookmarkAction)
- self._qpart.nextBookmarkAction = None
-
- def clear(self, startBlock, endBlock):
- """Clear bookmarks on block range including start and end
- """
- for block in qutepart.iterateBlocksFrom(startBlock):
- self._setBlockMarked(block, False)
- if block == endBlock:
- break
-
- def isBlockMarked(self, block):
- """Check if block is bookmarked
- """
- return self._markArea.isBlockMarked(block)
-
- def _setBlockMarked(self, block, marked):
- """Set block bookmarked
- """
- self._markArea.setBlockValue(block, 1 if marked else 0)
-
- def _toggleBookmark(self, block):
- self._markArea.toggleBlockMark(block)
- self._markArea.update()
-
- def _onToggleBookmark(self):
- """Toogle Bookmark action triggered
- """
- self._toggleBookmark(self._qpart.textCursor().block())
-
- def _onPrevBookmark(self):
- """Previous Bookmark action triggered. Move cursor
- """
- for block in qutepart.iterateBlocksBackFrom(self._qpart.textCursor().block().previous()):
- if self.isBlockMarked(block):
- self._qpart.setTextCursor(QTextCursor(block))
- return
-
- def _onNextBookmark(self):
- """Previous Bookmark action triggered. Move cursor
- """
- for block in qutepart.iterateBlocksFrom(self._qpart.textCursor().block().next()):
- if self.isBlockMarked(block):
- self._qpart.setTextCursor(QTextCursor(block))
- return
diff --git a/Orange/widgets/data/utils/pythoneditor/brackethlighter.py b/Orange/widgets/data/utils/pythoneditor/brackethighlighter.py
similarity index 84%
rename from Orange/widgets/data/utils/pythoneditor/brackethlighter.py
rename to Orange/widgets/data/utils/pythoneditor/brackethighlighter.py
index 6e9e1a1fd31..00555695217 100644
--- a/Orange/widgets/data/utils/pythoneditor/brackethlighter.py
+++ b/Orange/widgets/data/utils/pythoneditor/brackethighlighter.py
@@ -7,21 +7,19 @@
as published by the Free Software Foundation, version 2.1 of the license.
This is compatible with Orange3's GPL-3.0 license.
"""
-"""Bracket highlighter.
-Calculates list of QTextEdit.ExtraSelection
-"""
-
import time
-from PyQt5.QtCore import Qt
-from PyQt5.QtGui import QTextCursor
-from PyQt5.QtWidgets import QTextEdit
+from AnyQt.QtCore import Qt
+from AnyQt.QtGui import QTextCursor, QColor
+from AnyQt.QtWidgets import QTextEdit
+
+# Bracket highlighter.
+# Calculates list of QTextEdit.ExtraSelection
class _TimeoutException(UserWarning):
"""Operation timeout happened
"""
- pass
class BracketHighlighter:
@@ -31,15 +29,18 @@ class BracketHighlighter:
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( (bracket, oposite)
- for (bracket, oposite) in zip(_START_BRACKETS + _END_BRACKETS, _END_BRACKETS + _START_BRACKETS))
+ _OPOSITE_BRACKET = dict(zip(_START_BRACKETS + _END_BRACKETS, _END_BRACKETS + _START_BRACKETS))
- currentMatchedBrackets = None # instance variable. None or ((block, columnIndex), (block, columnIndex))
+ # instance variable. None or ((block, columnIndex), (block, columnIndex))
+ currentMatchedBrackets = None
def _iterateDocumentCharsForward(self, block, startColumnIndex):
"""Traverse document forward. Yield (block, columnIndex, char)
@@ -93,16 +94,15 @@ def _findMatchingBracket(self, bracket, qpart, block, columnIndex):
depth = 1
oposite = self._OPOSITE_BRACKET[bracket]
- for block, columnIndex, char in charsGenerator:
- if qpart.isCode(block, columnIndex):
+ for b, c_index, char in charsGenerator:
+ if qpart.isCode(b, c_index):
if char == oposite:
depth -= 1
if depth == 0:
- return block, columnIndex
+ return b, c_index
elif char == bracket:
depth += 1
- else:
- return None, None
+ return None, None
def _makeMatchSelection(self, block, columnIndex, matched):
"""Make matched or unmatched QTextEdit.ExtraSelection
@@ -110,11 +110,12 @@ def _makeMatchSelection(self, block, columnIndex, matched):
selection = QTextEdit.ExtraSelection()
if matched:
- bgColor = Qt.green
+ fgColor = self.MATCHED_COLOR
else:
- bgColor = Qt.red
+ fgColor = self.UNMATCHED_COLOR
- selection.format.setBackground(bgColor)
+ selection.format.setForeground(fgColor)
+ selection.format.setBackground(Qt.white) # repaint hack
selection.cursor = QTextCursor(block)
selection.cursor.setPosition(block.position() + columnIndex)
selection.cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor)
@@ -126,7 +127,8 @@ def _highlightBracket(self, bracket, qpart, block, columnIndex):
Return tuple of QTextEdit.ExtraSelection's
"""
try:
- matchedBlock, matchedColumnIndex = self._findMatchingBracket(bracket, qpart, block, columnIndex)
+ matchedBlock, matchedColumnIndex = self._findMatchingBracket(bracket, qpart,
+ block, columnIndex)
except _TimeoutException: # not found, time is over
return[] # highlight nothing
diff --git a/Orange/widgets/data/utils/pythoneditor/completer.py b/Orange/widgets/data/utils/pythoneditor/completer.py
index 2087b6f0f5c..d09e1757e2e 100644
--- a/Orange/widgets/data/utils/pythoneditor/completer.py
+++ b/Orange/widgets/data/utils/pythoneditor/completer.py
@@ -1,491 +1,578 @@
-"""
-Adapted from a code editor component created
-for Enki editor as replacement for QScintilla.
-Copyright (C) 2020 Andrei Kopats
+import logging
+import html
+import sys
+from collections import namedtuple
+from os.path import join, dirname
-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.
-"""
-"""Autocompletion widget and logic
-"""
+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)
-import re
-import time
+from qtconsole.base_frontend_mixin import BaseFrontendMixin
-from PyQt5.QtCore import pyqtSignal, QAbstractItemModel, QEvent, QModelIndex, QObject, QSize, Qt, QTimer
-from PyQt5.QtWidgets import QListView
-from PyQt5.QtGui import QCursor
+log = logging.getLogger(__name__)
-from qutepart.htmldelegate import HTMLDelegate
+DEFAULT_COMPLETION_ITEM_WIDTH = 250
+JEDI_TYPES = frozenset({'module', 'class', 'instance', 'function', 'param',
+ 'path', 'keyword', 'property', 'statement', None})
-_wordPattern = "\w+"
-_wordRegExp = re.compile(_wordPattern)
-_wordAtEndRegExp = re.compile(_wordPattern + '$')
-_wordAtStartRegExp = re.compile('^' + _wordPattern)
+class HTMLDelegate(QStyledItemDelegate):
+ """With this delegate, a QListWidgetItem or a QTableItem can render HTML.
-# Maximum count of words, for which completion will be shown. Ignored, if completion invoked manually.
-MAX_VISIBLE_WORD_COUNT = 256
-
-
-class _GlobalUpdateWordSetTimer:
- """Timer updates word set, when editor is idle. (5 sec. after last change)
- Timer is global, for avoid situation, when all instances
- update set simultaneously
- """
- _IDLE_TIMEOUT_MS = 1000
-
- def __init__(self):
- self._timer = QTimer()
- self._timer.setSingleShot(True)
- self._timer.timeout.connect(self._onTimer)
- self._scheduledMethods = []
-
- def schedule(self, method):
- if not method in self._scheduledMethods:
- self._scheduledMethods.append(method)
- self._timer.start(self._IDLE_TIMEOUT_MS)
-
- def cancel(self, method):
- """Cancel scheduled method
- Safe method, may be called with not-scheduled method"""
- if method in self._scheduledMethods:
- self._scheduledMethods.remove(method)
-
- if not self._scheduledMethods:
- self._timer.stop()
-
- def _onTimer(self):
- method = self._scheduledMethods.pop()
- method()
- if self._scheduledMethods:
- self._timer.start(self._IDLE_TIMEOUT_MS)
-
-
-class _CompletionModel(QAbstractItemModel):
- """QAbstractItemModel implementation for a list of completion variants
-
- words attribute contains all words
- canCompleteText attribute contains text, which may be inserted with tab
+ Taken from https://stackoverflow.com/a/5443112/2399799
"""
- def __init__(self, wordSet):
- QAbstractItemModel.__init__(self)
- self._wordSet = wordSet
+ def __init__(self, parent, margin=0):
+ super().__init__(parent)
+ self._margin = margin
- def setData(self, wordBeforeCursor, wholeWord):
- """Set model information
- """
- self._typedText = wordBeforeCursor
- self.words = self._makeListOfCompletions(wordBeforeCursor, wholeWord)
- commonStart = self._commonWordStart(self.words)
- self.canCompleteText = commonStart[len(wordBeforeCursor):]
+ 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)
- self.layoutChanged.emit()
+ 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 hasWords(self):
- return len(self.words) > 0
+ def paint(self, painter, option, index):
+ options, doc = self._prepare_text_document(option, index)
- def tooManyWords(self):
- return len(self.words) > MAX_VISIBLE_WORD_COUNT
+ style = (QApplication.style() if options.widget is None
+ else options.widget.style())
+ options.text = ""
- def data(self, index, role):
- """QAbstractItemModel method implementation
- """
- if role == Qt.DisplayRole and \
- index.row() < len(self.words):
- text = self.words[index.row()]
- typed = text[:len(self._typedText)]
- canComplete = text[len(self._typedText):len(self._typedText) + len(self.canCompleteText)]
- rest = text[len(self._typedText) + len(self.canCompleteText):]
- if canComplete:
- # NOTE foreground colors are hardcoded, but I can't set background color of selected item (Qt bug?)
- # might look bad on some color themes
- return '' \
- '%s' \
- '%s' \
- '%s' \
- '' % (typed, canComplete, rest)
- else:
- return typed + rest
- else:
- return None
+ # 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)
- def rowCount(self, index = QModelIndex()):
- """QAbstractItemModel method implementation
- """
- return len(self.words)
+ ctx = QAbstractTextDocumentLayout.PaintContext()
- def typedText(self):
- """Get current typed text
- """
- return self._typedText
+ textRect = style.subElementRect(QStyle.SE_ItemViewItemText,
+ options, None)
+ painter.save()
- def _commonWordStart(self, words):
- """Get common start of all words.
- i.e. for ['blablaxxx', 'blablayyy', 'blazzz'] common start is 'bla'
- """
- if not words:
- return ''
+ painter.translate(textRect.topLeft() + QPoint(0, -3))
+ doc.documentLayout().draw(painter, ctx)
+ painter.restore()
- length = 0
- firstWord = words[0]
- otherWords = words[1:]
- for index, char in enumerate(firstWord):
- if not all([word[index] == char for word in otherWords]):
- break
- length = index + 1
+ def sizeHint(self, option, index):
+ _, doc = self._prepare_text_document(option, index)
+ return QSize(round(doc.idealWidth()), round(doc.size().height() - 2))
- return firstWord[:length]
- def _makeListOfCompletions(self, wordBeforeCursor, wholeWord):
- """Make list of completions, which shall be shown
- """
- onlySuitable = [word for word in self._wordSet \
- if word.startswith(wordBeforeCursor) and \
- word != wholeWord]
-
- return sorted(onlySuitable)
-
- """Trivial QAbstractItemModel methods implementation
+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)
"""
- def flags(self, index): return Qt.ItemIsEnabled | Qt.ItemIsSelectable
- def headerData(self, index): return None
- def columnCount(self, index): return 1
- def index(self, row, column, parent = QModelIndex()): return self.createIndex(row, column)
- def parent(self, index): return QModelIndex()
+ 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
-class _CompletionList(QListView):
- """Completion list widget
- """
- closeMe = pyqtSignal()
- itemSelected = pyqtSignal(int)
- tabPressed = pyqtSignal()
+ self.automatic = automatic
- _MAX_VISIBLE_ROWS = 20 # no any technical reason, just for better UI
+ 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()
- def __init__(self, qpart, model):
- QListView.__init__(self, qpart.viewport())
+ elif self.textedit.textCursor().position() < position:
+ # hide the text as we moved away from the position
+ self.hide()
+ return
- # ensure good selected item background on Windows
- palette = self.palette()
- palette.setColor(palette.Inactive, palette.Highlight, palette.color(palette.Active, palette.Highlight))
- self.setPalette(palette)
+ else:
+ self.completion_position = position
- self.setAttribute(Qt.WA_DeleteOnClose)
+ self.completion_list = completion_list
- self.setItemDelegate(HTMLDelegate(self))
+ # Check everything is in order
+ self.update_current()
- self._qpart = qpart
- self.setFont(qpart.font())
+ # If update_current called close, stop loading
+ if not self.completion_list:
+ return
- self.setCursor(QCursor(Qt.PointingHandCursor))
- self.setFocusPolicy(Qt.NoFocus)
+ # 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.setModel(model)
+ self.show()
+ self.setFocus()
+ self.raise_()
- self._selectedIndex = -1
+ self.textedit.position_widget_at_cursor(self)
- # if cursor moved, we shall close widget, if its position (and model) hasn't been updated
- self._closeIfNotUpdatedTimer = QTimer(self)
- self._closeIfNotUpdatedTimer.setInterval(200)
- self._closeIfNotUpdatedTimer.setSingleShot(True)
+ if not self.is_internal_console:
+ tooltip_point = self.rect().topRight()
+ tooltip_point = self.mapToGlobal(tooltip_point)
- self._closeIfNotUpdatedTimer.timeout.connect(self._afterCursorPositionChanged)
+ if self.completion_list is not None:
+ for completion in self.completion_list:
+ completion['point'] = tooltip_point
- qpart.installEventFilter(self)
+ # Show hint for first completion element
+ self.setCurrentRow(0)
- qpart.cursorPositionChanged.connect(self._onCursorPositionChanged)
+ # signal used for testing
+ self.sig_show_completions.emit(completion_list)
- self.clicked.connect(lambda index: self.itemSelected.emit(index.row()))
+ def set_language(self, language):
+ """Set the completion language."""
+ self._language = language.lower()
- self.updateGeometry()
- self.show()
+ 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).
- qpart.setFocus()
+ 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.
- def __del__(self):
- """Without this empty destructor Qt prints strange trace
- QObject::startTimer: QTimer can only be used with threads started with QThread
- when exiting
+ If no items are left on the list the autocompletion should stop
"""
- pass
+ 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 close(self):
- """Explicitly called destructor.
- Removes widget from the qpart
+ def is_up_to_date(self, item=None):
"""
- self._closeIfNotUpdatedTimer.stop()
- self._qpart.removeEventFilter(self)
- self._qpart.cursorPositionChanged.disconnect(self._onCursorPositionChanged)
+ 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)
- QListView.close(self)
+ @staticmethod
+ def check_can_complete(filter_text, current_word):
+ """Check if current_word matches filter_text."""
+ if not filter_text:
+ return True
- def sizeHint(self):
- """QWidget.sizeHint implementation
- Automatically resizes the widget according to rows count
+ if not current_word:
+ return True
- FIXME very bad algorithm. Remove all this margins, if you can
- """
- width = max([self.fontMetrics().width(word) \
- for word in self.model().words])
- width = width * 1.4 # FIXME bad hack. invent better formula
- width += 30 # margin
+ return str(filter_text).lower().startswith(
+ str(current_word).lower())
- # drawn with scrollbar without +2. I don't know why
- rowCount = min(self.model().rowCount(), self._MAX_VISIBLE_ROWS)
- height = self.sizeHintForRow(0) * (rowCount + 0.5) # + 0.5 row margin
+ def is_position_correct(self):
+ """Check if the position is correct."""
- return QSize(width, height)
+ if self.completion_position is None:
+ return False
- def minimumHeight(self):
- """QWidget.minimumSizeHint implementation
- """
- return self.sizeHintForRow(0) * 1.5 # + 0.5 row margin
+ cursor_position = self.textedit.textCursor().position()
- def _horizontalShift(self):
- """List should be plased such way, that typed text in the list is under
- typed text in the editor
- """
- strangeAdjustment = 2 # I don't know why. Probably, won't work on other systems and versions
- return self.fontMetrics().width(self.model().typedText()) + strangeAdjustment
+ # Can only go forward from the data we have
+ if cursor_position < self.completion_position:
+ return False
- def updateGeometry(self):
- """Move widget to point under cursor
- """
- WIDGET_BORDER_MARGIN = 5
- SCROLLBAR_WIDTH = 30 # just a guess
-
- sizeHint = self.sizeHint()
- width = sizeHint.width()
- height = sizeHint.height()
-
- cursorRect = self._qpart.cursorRect()
- parentSize = self.parentWidget().size()
-
- spaceBelow = parentSize.height() - cursorRect.bottom() - WIDGET_BORDER_MARGIN
- spaceAbove = cursorRect.top() - WIDGET_BORDER_MARGIN
-
- if height <= spaceBelow or \
- spaceBelow > spaceAbove:
- yPos = cursorRect.bottom()
- if height > spaceBelow and \
- spaceBelow > self.minimumHeight():
- height = spaceBelow
- width = width + SCROLLBAR_WIDTH
- else:
- if height > spaceAbove and \
- spaceAbove > self.minimumHeight():
- height = spaceAbove
- width = width + SCROLLBAR_WIDTH
- yPos = max(3, cursorRect.top() - height)
+ completion_text = self.textedit.get_current_word_and_position(
+ completion=True)
- xPos = cursorRect.right() - self._horizontalShift()
+ # If no text found, we must be at self.completion_position
+ if completion_text is None:
+ return self.completion_position == cursor_position
- if xPos + width + WIDGET_BORDER_MARGIN > parentSize.width():
- xPos = max(3, parentSize.width() - WIDGET_BORDER_MARGIN - width)
+ completion_text, text_position = completion_text
+ completion_text = str(completion_text)
- self.setGeometry(xPos, yPos, width, height)
- self._closeIfNotUpdatedTimer.stop()
+ # The position of text must compatible with completion_position
+ if not text_position <= self.completion_position <= (
+ text_position + len(completion_text)):
+ return False
- def _onCursorPositionChanged(self):
- """Cursor position changed. Schedule closing.
- Timer will be stopped, if widget position is being updated
- """
- self._closeIfNotUpdatedTimer.start()
+ return True
- def _afterCursorPositionChanged(self):
- """Widget position hasn't been updated after cursor position change, close widget
+ def update_current(self):
"""
- self.closeMe.emit()
-
- def eventFilter(self, object, event):
- """Catch events from qpart
- Move selection, select item, or close themselves
+ Update the displayed list.
"""
- if event.type() == QEvent.KeyPress and event.modifiers() == Qt.NoModifier:
- if event.key() == Qt.Key_Escape:
- self.closeMe.emit()
- return True
- elif event.key() == Qt.Key_Down:
- if self._selectedIndex + 1 < self.model().rowCount():
- self._selectItem(self._selectedIndex + 1)
- return True
- elif event.key() == Qt.Key_Up:
- if self._selectedIndex - 1 >= 0:
- self._selectItem(self._selectedIndex - 1)
- return True
- elif event.key() in (Qt.Key_Enter, Qt.Key_Return):
- if self._selectedIndex != -1:
- self.itemSelected.emit(self._selectedIndex)
- return True
- elif event.key() == Qt.Key_Tab:
- self.tabPressed.emit()
- return True
- elif event.type() == QEvent.FocusOut:
- self.closeMe.emit()
+ 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']
- return False
+ # Split by starting $ or language specific chars
+ chars = ['$']
+ if self._language == 'python':
+ chars.append('(')
- def _selectItem(self, index):
- """Select item in the list
- """
- self._selectedIndex = index
- self.setCurrentIndex(self.model().createIndex(index, 0))
+ 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(QObject):
- """Object listens Qutepart widget events, computes and shows autocompletion lists
- """
- _globalUpdateWordSetTimer = _GlobalUpdateWordSetTimer()
- _WORD_SET_UPDATE_MAX_TIME_SEC = 0.4
+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 = None
- self._completionOpenedManually = False
+ self._widget = CompletionWidget(self._qpart, self._qpart.parent())
+ self._opened_automatically = True
- self._keywords = set()
- self._customCompletions = set()
- self._wordSet = None
-
- qpart.textChanged.connect(self._onTextChanged)
- qpart.document().modificationChanged.connect(self._onModificationChanged)
+ self._complete()
def terminate(self):
"""Object deleted. Cancel timer
"""
- self._globalUpdateWordSetTimer.cancel(self._updateWordSet)
-
- def setKeywords(self, keywords):
- self._keywords = keywords
- self._updateWordSet()
-
- def setCustomCompletions(self, wordSet):
- self._customCompletions = wordSet
def isVisible(self):
- return self._widget is not None
-
- def _onTextChanged(self):
- """Text in the qpart changed. Update word set"""
- self._globalUpdateWordSetTimer.schedule(self._updateWordSet)
-
- def _onModificationChanged(self, modified):
- if not modified:
- self._closeCompletion()
-
- def _updateWordSet(self):
- """Make a set of words, which shall be completed, from text
- """
- self._wordSet = set(self._keywords) | set(self._customCompletions)
+ return self._widget.isVisible()
- start = time.time()
-
- for line in self._qpart.lines:
- for match in _wordRegExp.findall(line):
- self._wordSet.add(match)
- if time.time() - start > self._WORD_SET_UPDATE_MAX_TIME_SEC:
- """It is better to have incomplete word set, than to freeze the GUI"""
- break
+ def setup_appearance(self, size, font):
+ self._widget.setup_appearance(size, font)
def invokeCompletion(self):
"""Invoke completion manually"""
- if self.invokeCompletionIfAvailable(requestedByUser=True):
- self._completionOpenedManually = True
-
+ self._opened_automatically = False
+ self._complete()
- def _shouldShowModel(self, model, forceShow):
- if not model.hasWords():
- return False
+ def invokeCompletionIfAvailable(self):
+ if not self._opened_automatically:
+ return
+ self._complete()
- return forceShow or \
- (not model.tooManyWords())
+ def _show_completions(self, matches, pos):
+ self._widget.show_list(matches, pos, self._opened_automatically)
- def _createWidget(self, model):
- self._widget = _CompletionList(self._qpart, model)
- self._widget.closeMe.connect(self._closeCompletion)
- self._widget.itemSelected.connect(self._onCompletionListItemSelected)
- self._widget.tabPressed.connect(self._onCompletionListTabPressed)
+ def _close_completions(self):
+ self._widget.hide()
- def invokeCompletionIfAvailable(self, requestedByUser=False):
- """Invoke completion, if available. Called after text has been typed in qpart
- Returns True, if invoked
+ def _complete(self):
+ """ Performs completion at the current cursor location.
"""
- if self._qpart.completionEnabled and self._wordSet is not None:
- wordBeforeCursor = self._wordBeforeCursor()
- wholeWord = wordBeforeCursor + self._wordAfterCursor()
-
- forceShow = requestedByUser or self._completionOpenedManually
- if wordBeforeCursor:
- if len(wordBeforeCursor) >= self._qpart.completionThreshold or forceShow:
- if self._widget is None:
- model = _CompletionModel(self._wordSet)
- model.setData(wordBeforeCursor, wholeWord)
- if self._shouldShowModel(model, forceShow):
- self._createWidget(model)
- return True
- else:
- self._widget.model().setData(wordBeforeCursor, wholeWord)
- if self._shouldShowModel(self._widget.model(), forceShow):
- self._widget.updateGeometry()
-
- return True
-
- self._closeCompletion()
- return False
-
- def _closeCompletion(self):
- """Close completion, if visible.
- Delete widget
+ 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.
"""
- if self._widget is not None:
- self._widget.close()
- self._widget = None
- self._completionOpenedManually = False
+ info = self._request_info.get('complete')
+ if (info and info.id == rep['parent_header']['msg_id']):
+ content = rep['content']
- def _wordBeforeCursor(self):
- """Get word, which is located before cursor
- """
- cursor = self._qpart.textCursor()
- textBeforeCursor = cursor.block().text()[:cursor.positionInBlock()]
- match = _wordAtEndRegExp.search(textBeforeCursor)
- if match:
- return match.group(0)
- else:
- return ''
+ 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']
- def _wordAfterCursor(self):
- """Get word, which is located before cursor
- """
- cursor = self._qpart.textCursor()
- textAfterCursor = cursor.block().text()[cursor.positionInBlock():]
- match = _wordAtStartRegExp.search(textAfterCursor)
- if match:
- return match.group(0)
- else:
- return ''
+ start = max(start, 0)
- def _onCompletionListItemSelected(self, index):
- """Item selected. Insert completion to editor
- """
- model = self._widget.model()
- selectedWord = model.words[index]
- textToInsert = selectedWord[len(model.typedText()):]
- self._qpart.textCursor().insertText(textToInsert)
- self._closeCompletion()
-
- def _onCompletionListTabPressed(self):
- """Tab pressed on completion list
- Insert completable text, if available
+ 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.
"""
- canCompleteText = self._widget.model().canCompleteText
- if canCompleteText:
- self._qpart.textCursor().insertText(canCompleteText)
- self.invokeCompletionIfAvailable()
+ 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
index 9db5e7e874f..fc2265f5ba6 100644
--- a/Orange/widgets/data/utils/pythoneditor/editor.py
+++ b/Orange/widgets/data/utils/pythoneditor/editor.py
@@ -7,266 +7,61 @@
as published by the Free Software Foundation, version 2.1 of the license.
This is compatible with Orange3's GPL-3.0 license.
"""
-"""qutepart --- Code editor component for PyQt and Pyside
-=========================================================
-"""
-
+import re
import sys
-import os.path
-import logging
-import platform
-
-from PyQt5.QtCore import QRect, Qt, pyqtSignal
-from PyQt5.QtWidgets import QAction, QApplication, QDialog, QPlainTextEdit, QTextEdit, QWidget
-from PyQt5.QtPrintSupport import QPrintDialog
-from PyQt5.QtGui import QColor, QBrush, \
- QFont, \
- QIcon, QKeySequence, QPainter, QPen, QPalette, \
- QTextCharFormat, QTextCursor, \
- QTextBlock, QTextFormat
-
-from qutepart.syntax import SyntaxManager
-import qutepart.version
-
-
-if 'sphinx-build' not in sys.argv[0]:
- # See explanation near `import sip` above
- from qutepart.syntaxhlighter import SyntaxHighlighter
- from qutepart.brackethlighter import BracketHighlighter
- from qutepart.completer import Completer
- from qutepart.lines import Lines
- from qutepart.rectangularselection import RectangularSelection
- import qutepart.sideareas
- from qutepart.indenter import Indenter
- import qutepart.vim
-
- def setPositionInBlock(cursor, positionInBlock, anchor=QTextCursor.MoveAnchor):
- return cursor.setPosition(cursor.block().position() + positionInBlock, anchor)
-
-
-VERSION = qutepart.version.VERSION
-
-
-logger = logging.getLogger('qutepart')
-consoleHandler = logging.StreamHandler()
-consoleHandler.setFormatter(logging.Formatter("qutepart: %(message)s"))
-logger.addHandler(consoleHandler)
-
-logger.setLevel(logging.ERROR)
-
-
-# After logging setup
-import qutepart.syntax.loader
-binaryParserAvailable = qutepart.syntax.loader.binaryParserAvailable
-
-
-_ICONS_PATH = os.path.join(os.path.dirname(__file__), 'icons')
-
-def getIcon(iconFileName):
- icon = QIcon.fromTheme(iconFileName)
- if icon.name() != iconFileName:
- # Use bundled fallback icon
- icon = QIcon(os.path.join(_ICONS_PATH, iconFileName))
- return icon
-
-
-#Define for old Qt versions methods, which appeared in 4.7
-if not hasattr(QTextCursor, 'positionInBlock'):
- def _positionInBlock(cursor):
- return cursor.position() - cursor.block().position()
- QTextCursor.positionInBlock = _positionInBlock
-
-
-
-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 Qutepart(QPlainTextEdit):
- '''Qutepart is based on QPlainTextEdit, and you can use QPlainTextEdit methods,
- if you don't see some functionality here.
-
- **Text**
-
- ``text`` attribute holds current text. It may be read and written.::
-
- qpart.text = readFile()
- saveFile(qpart.text)
-
- This attribute always returns text, separated with ``\\n``. Use ``textForSaving()`` for get original text.
-
- It is recommended to use ``lines`` attribute whenever possible,
- because access to ``text`` might require long time on big files.
- Attribute is cached, only first read access after text has been changed in slow.
-
- **Selected text**
-
- ``selectedText`` attribute holds selected text. It may be read and written.
- Write operation replaces selection with new text. If nothing is selected - just inserts text::
-
- print qpart.selectedText # print selection
- qpart.selectedText = 'new text' # replace selection
-
- **Text lines**
-
- ``lines`` attribute, which represents text as list-of-strings like object
- and allows to modify it. Examples::
-
- qpart.lines[0] # get the first line of the text
- qpart.lines[-1] # get the last line of the text
- qpart.lines[2] = 'new text' # replace 3rd line value with 'new text'
- qpart.lines[1:4] # get 3 lines of text starting from the second line as list of strings
- qpart.lines[1:4] = ['new line 2', 'new line3', 'new line 4'] # replace value of 3 lines
- del qpart.lines[3] # delete 4th line
- del qpart.lines[3:5] # delete lines 4, 5, 6
-
- len(qpart.lines) # get line count
-
- qpart.lines.append('new line') # append new line to the end
- qpart.lines.insert(1, 'new line') # insert new line before line 1
-
- print qpart.lines # print all text as list of strings
-
- # iterate over lines.
- for lineText in qpart.lines:
- doSomething(lineText)
-
- qpart.lines = ['one', 'thow', 'three'] # replace whole text
-
- **Position and selection**
-
- * ``cursorPosition`` - cursor position as ``(line, column)``. Lines are numerated from zero. If column is set to ``None`` - cursor will be placed before first non-whitespace character. If line or column is bigger, than actual file, cursor will be placed to the last line, to the last column
- * ``absCursorPosition`` - cursor position as offset from the beginning of text.
- * ``selectedPosition`` - selection coordinates as ``((startLine, startCol), (cursorLine, cursorCol))``.
- * ``absSelectedPosition`` - selection coordinates as ``(startPosition, cursorPosition)`` where position is offset from the beginning of text.
- Rectangular selection is not available via API currently.
-
- **EOL, indentation, edge, current line**
-
- * ``eol`` - End Of Line character. Supported values are ``\\n``, ``\\r``, ``\\r\\n``. See comments for ``textForSaving()``
- * ``indentWidth`` - Width of ``Tab`` character, and width of one indentation level. Default is ``4``.
- * ``indentUseTabs`` - If True, ``Tab`` character inserts ``\\t``, otherwise - spaces. Default is ``False``.
- * ``lineLengthEdge`` - If not ``None`` - maximal allowed line width (i.e. 80 chars). Longer lines are marked with red (see ``lineLengthEdgeColor``) line. Default is ``None``.
- * ``lineLengthEdgeColor`` - Color of line length edge line. Default is red.
- * ``drawSolidEdge`` - Draw the edge as a solid vertical line. Default is ``False``.
- * ``drawIndentations`` - Draw indentations. Default is ``True``.
- * ``currentLineColor`` - Color of the current line background. If None then the current line is not highlighted. Default: #ffffa3
-
- **Visible white spaces**
-
- * ``drawIncorrectIndentation`` - Draw trailing whitespaces, tabs if text is indented with spaces, spaces if text is indented with tabs. Default is ``True``. Doesn't have any effect if ``drawAnyWhitespace`` is ``True``.
- * ``drawAnyWhitespace`` - Draw trailing and other whitespaces, used as indentation. Default is ``False``.
-
- **Autocompletion**
-
- Qutepart supports autocompletion, based on document contents.
- It is enabled, if ``completionEnabled`` is ``True``.
- ``completionThreshold`` is count of typed symbols, after which completion is shown.
-
- **Linters support**
-
- * ``lintMarks`` Linter messages as {lineNumber: (type, text)} dictionary. Cleared on any edit operation. Type is one of `Qutepart.LINT_ERROR, Qutepart.LINT_WARNING, Qutepart.LINT_NOTE)
-
- **Vim mode**
-
- ``vimModeEnabled`` - read-write property switches Vim mode. See also ``vimModeEnabledChanged``.
- ``vimModeIndication`` - An application shall display a label, which shows current Vim mode. This read-only property contains (QColor, str) to be displayed on the label. See also ``vimModeIndicationChanged``.
-
- **Actions**
-
- Component contains list of actions (QAction instances).
- Actions can be insered to some menu, a shortcut and an icon can be configured.
-
- Bookmarks:
- * ``toggleBookmarkAction`` - Set/Clear bookmark on current block
- * ``nextBookmarkAction`` - Jump to next bookmark
- * ``prevBookmarkAction`` - Jump to previous bookmark
+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
- Scroll:
+from pygments.token import Token
+from qtconsole.pygments_highlighter import PygmentsHighlighter, PygmentsBlockUserData
- * ``scrollUpAction`` - Scroll viewport Up
- * ``scrollDownAction`` - Scroll viewport Down
- * ``selectAndScrollUpAction`` - Select 1 line Up and scroll
- * ``selectAndScrollDownAction`` - Select 1 line Down and scroll
+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
- Indentation:
- * ``increaseIndentAction`` - Increase indentation by 1 level
- * ``decreaseIndentAction`` - Decrease indentation by 1 level
- * ``autoIndentLineAction`` - Autoindent line
- * ``indentWithSpaceAction`` - Indent all selected lines by 1 space symbol
- * ``unIndentWithSpaceAction`` - Unindent all selected lines by 1 space symbol
+# 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
- Lines:
- * ``moveLineUpAction`` - Move line Up
- * ``moveLineDownAction`` - Move line Down
- * ``deleteLineAction`` - Delete line
- * ``copyLineAction`` - Copy line
- * ``pasteLineAction`` - Paste line
- * ``cutLineAction`` - Cut line
- * ``duplicateLineAction`` - Duplicate line
+def setPositionInBlock(cursor, positionInBlock, anchor=QTextCursor.MoveAnchor):
+ return cursor.setPosition(cursor.block().position() + positionInBlock, anchor)
- Other:
- * ``undoAction`` - Undo
- * ``redoAction`` - Redo
- * ``invokeCompletionAction`` - Invoke completion
- * ``printAction`` - Print file
- **Text modification and Undo/Redo**
-
- Sometimes, it is required to make few text modifications, which are Undo-Redoble as atomic operation.
- i.e. you want to indent (insert indentation) few lines of text, but user shall be able to
- Undo it in one step. In this case, you can use Qutepart as a context manager.::
-
- with qpart:
- qpart.modifySomeText()
- qpart.modifyOtherText()
-
- Nested atomic operations are joined in one operation
-
- **Signals**
-
- * ``userWarning(text)``` Warning, which shall be shown to the user on status bar. I.e. 'Rectangular selection area is too big'
- * ``languageChanged(langName)``` Language has changed. See also ``language()``
- * ``indentWidthChanged(int)`` Indentation width changed. See also ``indentWidth``
- * ``indentUseTabsChanged(bool)`` Indentation uses tab property changed. See also ``indentUseTabs``
- * ``eolChanged(eol)`` EOL mode changed. See also ``eol``.
- * ``vimModeEnabledChanged(enabled) Vim mode has been enabled or disabled.
- * ``vimModeIndicationChanged(color, text)`` Vim mode changed. Parameters contain color and text to be displayed on an indicator. See also ``vimModeIndication``
-
- **Syntax parser**
-
- Qutepart supports two syntax parsers. One of them is written in C (faster) and the
- other in Python (slower). By default qutepart tries to load the faster parser and
- falls back to the slower one if there are import errors.
- If by some reasons a slower Python parser is preferred then qutepart can be
- instructed not to try to import the C parser. In order to do so an environment
- variable can be used (it needs to be set before the first import of qutepart), e.g.::
+def iterateBlocksFrom(block):
+ """Generator, which iterates QTextBlocks from block until the End of a document
+ """
+ while block.isValid():
+ yield block
+ block = block.next()
- import os
- os.environ['QPART_CPARSER'] = 'N' # Python written syntax parser to be used
- import qutepart
+def iterateBlocksBackFrom(block):
+ """Generator, which iterates QTextBlocks from block until the Start of a document
+ """
+ while block.isValid():
+ yield block
+ block = block.previous()
- **Public methods**
- '''
- userWarning = pyqtSignal(str)
- languageChanged = pyqtSignal(str)
- indentWidthChanged = pyqtSignal(int)
- indentUseTabsChanged = pyqtSignal(bool)
- eolChanged = pyqtSignal(str)
- vimModeIndicationChanged = pyqtSignal(QColor, str)
- vimModeEnabledChanged = pyqtSignal(bool)
+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'
@@ -277,13 +72,7 @@ class Qutepart(QPlainTextEdit):
_DEFAULT_COMPLETION_THRESHOLD = 3
_DEFAULT_COMPLETION_ENABLED = True
- _globalSyntaxManager = SyntaxManager()
-
- def __init__(self,
- needMarkArea=True,
- needLineNumbers=True,
- needCompleter=True,
- *args):
+ def __init__(self, *args):
QPlainTextEdit.__init__(self, *args)
self.setAttribute(Qt.WA_KeyCompression, False) # vim can't process compressed keys
@@ -298,7 +87,7 @@ def __init__(self,
self._indenter = Indenter(self)
self._lineLengthEdge = None
self._lineLengthEdgeColor = QColor(255, 0, 0, 128)
- self._currentLineColor = QColor('#ffffa3')
+ self._currentLineColor = QColor('#ffffff')
self._atomicModificationDepth = 0
self.drawIncorrectIndentation = True
@@ -319,31 +108,30 @@ def __init__(self,
palette.setColor(QPalette.Text, QColor('#000000'))
self.setPalette(palette)
- self._highlighter = None
self._bracketHighlighter = BracketHighlighter()
self._lines = Lines(self)
self.completionThreshold = self._DEFAULT_COMPLETION_THRESHOLD
self.completionEnabled = self._DEFAULT_COMPLETION_ENABLED
- self._completer = None
- if needCompleter:
- self._completer = Completer(self)
+ 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._margins = []
- self._totalMarginWidth = -1
-
- if needLineNumbers:
- self.addMargin(qutepart.sideareas.LineNumberArea(self))
- if needMarkArea:
- self.addMargin(qutepart.sideareas.MarkArea(self))
+ self._line_number_margin = LineNumberArea(self)
+ self._marginWidth = -1
self._nonVimExtraSelections = []
- self._userExtraSelections = [] # we draw bracket highlighting, current line and extra selections by user
+ # we draw bracket highlighting, current line and extra selections by user
+ self._userExtraSelections = []
self._userExtraSelectionFormat = QTextCharFormat()
self._userExtraSelectionFormat.setBackground(QBrush(QColor('#ffee00')))
@@ -354,29 +142,8 @@ def __init__(self,
self.textChanged.connect(self._resetCachedText)
self.textChanged.connect(self._clearLintMarks)
- fontFamilies = {'Windows':'Courier New',
- 'Darwin': 'Menlo'}
- fontFamily = fontFamilies.get(platform.system(), 'Monospace')
- self.setFont(QFont(fontFamily))
-
self._updateExtraSelections()
- 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._highlighter is not None:
- self._highlighter.terminate()
-
- if self._vim is not None:
- self._vim.terminate()
-
def _initActions(self):
"""Init shortcuts for text editing
"""
@@ -385,8 +152,8 @@ 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))
+ # if iconFileName is not None:
+ # action.setIcon(getIcon(iconFileName))
keySeq = shortcut if isinstance(shortcut, QKeySequence) else QKeySequence(shortcut)
action.setShortcut(keySeq)
@@ -397,33 +164,45 @@ def createAction(text, shortcut, slot, iconFileName=None):
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),
+ lambda: self._onShortcutScroll(down=False),
'go-up')
self.scrollDownAction = createAction('Scroll down', 'Ctrl+Down',
- lambda: self._onShortcutScroll(down = True),
+ lambda: self._onShortcutScroll(down=True),
'go-down')
self.selectAndScrollUpAction = createAction('Select and scroll Up', 'Ctrl+Shift+Up',
- lambda: self._onShortcutSelectAndScroll(down = False))
+ lambda: self._onShortcutSelectAndScroll(
+ down=False))
self.selectAndScrollDownAction = createAction('Select and scroll Down', 'Ctrl+Shift+Down',
- lambda: self._onShortcutSelectAndScroll(down = True))
+ 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))
+ 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,
@@ -432,100 +211,314 @@ def createAction(text, shortcut, slot, iconFileName=None):
self.redo, 'edit-redo')
self.moveLineUpAction = createAction('Move line up', 'Alt+Up',
- lambda: self._onShortcutMoveLine(down = False), 'go-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)
- self.invokeCompletionAction = createAction('Invoke completion', 'Ctrl+Space', self._onCompletion)
+ 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()
- # other
- self.printAction = createAction('Print', 'Ctrl+P', self._onShortcutPrint, 'document-print')
+ 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 __enter__(self):
- """Context management method.
- Begin atomic modification
+ def _onShortcutScroll(self, down):
+ """Ctrl+Up/Down pressed, scroll viewport
"""
- self._atomicModificationDepth = self._atomicModificationDepth + 1
- if self._atomicModificationDepth == 1:
- self.textCursor().beginEditBlock()
+ value = self.verticalScrollBar().value()
+ if down:
+ value += 1
+ else:
+ value -= 1
+ self.verticalScrollBar().setValue(value)
- def __exit__(self, exc_type, exc_value, traceback):
- """Context management method.
- End atomic modification
+ def _onShortcutSelectAndScroll(self, down):
+ """Ctrl+Shift+Up/Down pressed.
+ Select line and scroll viewport
"""
- self._atomicModificationDepth = self._atomicModificationDepth - 1
- if self._atomicModificationDepth == 0:
- self.textCursor().endEditBlock()
+ cursor = self.textCursor()
+ cursor.movePosition(QTextCursor.Down if down else QTextCursor.Up, QTextCursor.KeepAnchor)
+ self.setTextCursor(cursor)
+ self._onShortcutScroll(down)
- if exc_type is not None:
- return False
+ def _onShortcutHome(self, select):
+ """Home pressed. Run a state machine:
- def setFont(self, font):
- pass # suppress dockstring for non-public method
- """Set font and update tab stop width
+ 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.
"""
- self._fontBackup = font
- QPlainTextEdit.setFont(self, font)
- self._updateTabStopWidth()
+ # 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
- # 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.getMargin("line_numbers")
- if lineNumbersMargin:
- lineNumbersMargin.setFont(font)
+ # 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)
- def showEvent(self, ev):
- pass # suppress dockstring for non-public method
- """ Qt 5.big automatically changes font when adding document to workspace. Workaround this bug """
- super().setFont(self._fontBackup)
- return super().showEvent(ev)
+ self.setTextCursor(cursor)
- def _updateTabStopWidth(self):
- """Update tabstop width after font or indentation changed
+ def _selectLines(self, startBlockNumber, endBlockNumber):
+ """Select whole lines
"""
- self.setTabStopWidth(self.fontMetrics().width(' ' * self._indenter.width))
+ 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)
- @property
- def lines(self):
- return self._lines
+ def _selectedBlocks(self):
+ """Return selected blocks and tuple (startBlock, endBlock)
+ """
+ cursor = self.textCursor()
+ return self.document().findBlock(cursor.selectionStart()), \
+ self.document().findBlock(cursor.selectionEnd())
- @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 _selectedBlockNumbers(self):
+ """Return selected block numbers and tuple (startBlockNumber, endBlockNumber)
+ """
+ startBlock, endBlock = self._selectedBlocks()
+ return startBlock.blockNumber(), endBlock.blockNumber()
- def _resetCachedText(self):
- """Reset toPlainText() result cache
+ 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
"""
- self._cachedText = None
+ startBlock, endBlock = self._selectedBlocks()
- @property
- def text(self):
- if self._cachedText is None:
- self._cachedText = self.toPlainText()
+ startBlockNumber = startBlock.blockNumber()
+ endBlockNumber = endBlock.blockNumber()
- return self._cachedText
+ def _moveBlock(block, newNumber):
+ text = block.text()
+ with self:
+ del self.lines[block.blockNumber()]
+ self.lines.insert(newNumber, text)
- @text.setter
- def text(self, text):
- self.setPlainText(text)
+ if down: # move next block up
+ blockToMove = endBlock.next()
+ if not blockToMove.isValid():
+ return
- 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
+ _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):
@@ -665,30 +658,6 @@ def _clearLintMarks(self):
self._lintMarks = {}
self.update()
- @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 = qutepart.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 drawSolidEdge(self):
return self._drawSolidEdge
@@ -745,7 +714,8 @@ def currentLineColor(self, val):
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 ``pos`` is an integer, it is interpreted as absolute position,
+ if a tuple - as ``(line, column)``
"""
if isinstance(pos, tuple):
pos = self.mapToAbsPosition(*pos)
@@ -767,171 +737,12 @@ def replaceText(self, pos, length, 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)``
+ If ``pos`` is an integer, it is interpreted as absolute position,
+ if a tuple - as ``(line, column)``
"""
return self.replaceText(pos, 0, text)
- def detectSyntax(self,
- xmlFileName=None,
- mimeType=None,
- language=None,
- sourceFilePath=None,
- firstLine=None):
- """Get syntax by next parameters (fill as many, as known):
-
- * name of XML file with syntax definition
- * MIME type of source file
- * Programming language name
- * Source file path
- * First line of source file
-
- First parameter in the list has the hightest priority.
- Old syntax is always cleared, even if failed to detect new.
-
- Method returns ``True``, if syntax is detected, and ``False`` otherwise
- """
- oldLanguage = self.language()
-
- self.clearSyntax()
-
- syntax = self._globalSyntaxManager.getSyntax(xmlFileName=xmlFileName,
- mimeType=mimeType,
- languageName=language,
- sourceFilePath=sourceFilePath,
- firstLine=firstLine)
-
- if syntax is not None:
- self._highlighter = SyntaxHighlighter(syntax, self)
- self._indenter.setSyntax(syntax)
- if self._completer:
- keywords = {kw for kwList in syntax.parser.lists.values() for kw in kwList}
- self._completer.setKeywords(keywords)
-
- newLanguage = self.language()
- if oldLanguage != newLanguage:
- self.languageChanged.emit(newLanguage)
-
- return syntax is not None
-
- def clearSyntax(self):
- """Clear syntax. Disables syntax highlighting
-
- This method might take long time, if document is big. Don't call it if you don't have to (i.e. in destructor)
- """
- if self._highlighter is not None:
- self._highlighter.terminate()
- self._highlighter = None
- self.languageChanged.emit(None)
-
- def language(self):
- """Get current language name.
- Return ``None`` for plain text
- """
- if self._highlighter is None:
- return None
- else:
- return self._highlighter.syntax().name
-
- def setCustomCompletions(self, wordSet):
- """Add a set of custom completions to the editors completions.
-
- This set is managed independently of the set of keywords and words from
- the current document, and can thus be changed at any time.
-
- """
- if not isinstance(wordSet, set):
- raise TypeError('"wordSet" is not a set: %s' % type(wordSet))
- if self._completer:
- self._completer.setCustomCompletions(wordSet)
-
- def isHighlightingInProgress(self):
- """Check if text highlighting is still in progress
- """
- return self._highlighter is not None and \
- self._highlighter.isInProgress()
-
- 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)
-
- return self._highlighter is None or \
- self._highlighter.isCode(block, column)
-
- def isComment(self, line, column):
- """Check if text at given position is a comment. Including block comments and here documents.
-
- If language is not known, or text is not parsed yet, ``False`` is returned
- """
- return self._highlighter is not None and \
- self._highlighter.isComment(self.document().findBlockByNumber(line), column)
-
- def isBlockComment(self, line, column):
- """Check if text at given position is a block comment.
-
- If language is not known, or text is not parsed yet, ``False`` is returned
- """
- return self._highlighter is not None and \
- self._highlighter.isBlockComment(self.document().findBlockByNumber(line), column)
-
- def isHereDoc(self, line, column):
- """Check if text at given position is a here document.
-
- If language is not known, or text is not parsed yet, ``False`` is returned
- """
- return self._highlighter is not None and \
- self._highlighter.isHereDoc(self.document().findBlockByNumber(line), column)
-
- 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 updateViewport(self):
- pass # suppress docstring for non-public method
"""Recalculates geometry for all the margins and the editor viewport
"""
cr = self.contentsRect()
@@ -939,35 +750,56 @@ def updateViewport(self):
top = cr.top()
height = cr.height()
- totalMarginWidth = 0
- for margin in self._margins:
- if not margin.isHidden():
- width = margin.width()
- margin.setGeometry(QRect(currentX, top, width, height))
- currentX += width
- totalMarginWidth += width
+ 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._totalMarginWidth != totalMarginWidth:
- self._totalMarginWidth = totalMarginWidth
+ if self._marginWidth != marginWidth:
+ self._marginWidth = marginWidth
self.updateViewportMargins()
else:
self._setSolidEdgeGeometry()
def updateViewportMargins(self):
- pass # suppress docstring for non-public method
- """Sets the viewport margins and the solid edge geometry
- """
- self.setViewportMargins(self._totalMarginWidth, 0, 0, 0)
+ """Sets the viewport margins and the solid edge geometry"""
+ self.setViewportMargins(self._marginWidth, 0, 0, 0)
self._setSolidEdgeGeometry()
- def resizeEvent(self, event):
- pass # suppress docstring for non-public method
- """QWidget.resizeEvent() implementation.
- Adjust line number area
+ 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
"""
- QPlainTextEdit.resizeEvent(self, event)
- self.updateViewport()
- return
+ 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"""
@@ -978,32 +810,36 @@ def _setSolidEdgeGeometry(self):
# cursor rectangle left edge for the very first character usually
# gives 4
x = self.fontMetrics().width('9' * self._lineLengthEdge) + \
- self._totalMarginWidth + \
+ self._marginWidth + \
self.contentsMargins().left() + \
self.__cursorRect(self.firstVisibleBlock(), 0, offset=0).left()
self._solidEdgeLine.setGeometry(QRect(x, cr.top(), 1, cr.bottom()))
- def _insertNewBlock(self):
- """Enter pressed.
- Insert properly indented block
+ viewport_margins_updated = Signal(float)
+
+ def setViewportMargins(self, left, top, right, bottom):
"""
- 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()
+ 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):
- pass # suppress docstring for non-API method, used by internal classes
"""Text in current block from start to cursor position
"""
cursor = self.textCursor()
return cursor.block().text()[:cursor.positionInBlock()]
def keyPressEvent(self, event):
- pass # suppress dockstring for non-public method
"""QPlainTextEdit.keyPressEvent() implementation.
Catch events, which may not be catched with QShortcut and call slots
"""
@@ -1043,7 +879,18 @@ def typeOverwrite(text):
cursor.deleteChar()
cursor.insertText(text)
- if event.matches(QKeySequence.InsertParagraphSeparator):
+ # 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
@@ -1060,24 +907,24 @@ def typeOverwrite(text):
else:
self.setOverwriteMode(not self.overwriteMode())
elif event.key() == Qt.Key_Backspace and \
- shouldUnindentWithBackspace():
+ shouldUnindentWithBackspace():
self._indenter.onShortcutUnindentWithBackspace()
elif event.key() == Qt.Key_Backspace and \
- not cursor.hasSelection() and \
- self.overwriteMode() and \
- cursor.positionInBlock() > 0:
+ not cursor.hasSelection() and \
+ self.overwriteMode() and \
+ cursor.positionInBlock() > 0:
backspaceOverwrite()
elif self.overwriteMode() and \
- event.text() and \
- qutepart.vim.isChar(event) and \
- not cursor.hasSelection() and \
- cursor.positionInBlock() < cursor.block().length():
+ 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):
+ self._vim.keyPressEvent(event):
return
else:
self._onShortcutHome(select=False)
@@ -1086,58 +933,373 @@ def typeOverwrite(text):
elif self._rectangularSelection.isExpandKeyEvent(event):
self._rectangularSelection.onExpandKeyEvent(event)
elif shouldAutoIndent(event):
- with self:
- super(Qutepart, self).keyPressEvent(event)
- self._indenter.autoIndentBlock(cursor.block(), event.text())
+ 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:
- if event.text() and event.modifiers() == Qt.AltModifier:
- return # alt+letter is a shortcut. Not mine
- else:
- self._lastKeyPressProcessedByParent = True
- super(Qutepart, self).keyPressEvent(event)
+ # 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
- """
+ # A hacky way to do not show completion list after a event, processed by vim
text = event.text()
- textTyped = (text and \
+ textTyped = (text and
event.modifiers() in (Qt.NoModifier, Qt.ShiftModifier)) and \
- (text.isalpha() or text.isdigit() or text == '_')
+ (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 or \
- (event.key() == Qt.Key_Backspace and self._completer.isVisible()):
+ if (textTyped and self.auto_invoke_completions) \
+ or dotTyped or importTyped:
self._completer.invokeCompletionIfAvailable()
- super(Qutepart, self).keyReleaseEvent(event)
+ super().keyReleaseEvent(event)
def mousePressEvent(self, mouseEvent):
- pass # suppress docstring for non-public method
if mouseEvent.modifiers() in RectangularSelection.MOUSE_MODIFIERS and \
- mouseEvent.button() == Qt.LeftButton:
+ mouseEvent.button() == Qt.LeftButton:
self._rectangularSelection.mousePressEvent(mouseEvent)
else:
- super(Qutepart, self).mousePressEvent(mouseEvent)
+ super().mousePressEvent(mouseEvent)
def mouseMoveEvent(self, mouseEvent):
- pass # suppress docstring for non-public method
if mouseEvent.modifiers() in RectangularSelection.MOUSE_MODIFIERS and \
- mouseEvent.buttons() == Qt.LeftButton:
+ mouseEvent.buttons() == Qt.LeftButton:
self._rectangularSelection.mouseMoveEvent(mouseEvent)
else:
- super(Qutepart, self).mouseMoveEvent(mouseEvent)
+ super().mouseMoveEvent(mouseEvent)
def _chooseVisibleWhitespace(self, text):
result = [False for _ in range(len(text))]
@@ -1149,11 +1311,11 @@ def _chooseVisibleWhitespace(self, text):
# 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())):
+ (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
@@ -1170,9 +1332,9 @@ def _chooseVisibleWhitespace(self, text):
for index in range(column, column + self.indentWidth):
result[index] = True
while index < lastNonSpaceColumn and \
- text[index] == ' ':
- result[index] = True
- index += 1
+ text[index] == ' ':
+ result[index] = True
+ index += 1
column = index
else:
# Find tabs:
@@ -1233,8 +1395,8 @@ def effectiveEdgePos(text):
currentWidth += 1
if currentWidth > self._lineLengthEdge:
return pos
- else: # line too narrow, probably visible \t width is small
- return -1
+ # line too narrow, probably visible \t width is small
+ return -1
def drawEdgeLine(block, edgePos):
painter.setPen(QPen(QBrush(self._lineLengthEdgeColor), 0))
@@ -1242,10 +1404,26 @@ def drawEdgeLine(block, edgePos):
painter.drawLine(rect.topLeft(), rect.bottomLeft())
def drawIndentMarker(block, column):
- painter.setPen(QColor(Qt.blue).lighter())
+ 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
@@ -1261,38 +1439,26 @@ def drawIndentMarker(block, column):
text = block.text()
if not self.drawAnyWhitespace:
column = indentWidthChars
- 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
+ drawIndentMarkers(block, text, column)
# Draw edge, but not over a cursor
if not self._drawSolidEdge:
edgePos = effectiveEdgePos(block.text())
- if edgePos != -1 and edgePos != cursorPos[1]:
+ if edgePos not in (-1, cursorPos[1]):
drawEdgeLine(block, edgePos)
if self.drawAnyWhitespace or \
- self.drawIncorrectIndentation:
+ self.drawIncorrectIndentation:
text = block.text()
for column, draw in enumerate(self._chooseVisibleWhitespace(text)):
if draw:
drawWhiteSpace(block, column, text[column])
def paintEvent(self, event):
- pass # suppress dockstring for non-public method
"""Paint event
Draw indentation markers after main contents is drawn
"""
- super(Qutepart, self).paintEvent(event)
+ super().paintEvent(event)
self._drawIndentMarkersAndEdge(event.rect())
def _currentLineExtraSelections(self):
@@ -1312,315 +1478,351 @@ def makeSelection(cursor):
rectangularSelectionCursors = self._rectangularSelection.cursors()
if rectangularSelectionCursors:
return [makeSelection(cursor) \
- for cursor in rectangularSelectionCursors]
+ for cursor in rectangularSelectionCursors]
else:
return [makeSelection(self.textCursor())]
- 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 _onShortcutIndent(self):
- if self.textCursor().hasSelection():
- self._indenter.onChangeSelectedBlocksIndent(increase=True)
+ 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:
- self._indenter.onShortcutIndentAfterCursor()
+ super().insertFromMimeData(source)
- 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 __cursorRect(self, block, column, offset):
+ cursor = QTextCursor(block)
+ setPositionInBlock(cursor, column)
+ return self.cursorRect(cursor).translated(offset, 0)
- def _onShortcutSelectAndScroll(self, down):
- """Ctrl+Shift+Up/Down pressed.
- Select line and scroll viewport
+ def get_current_word_and_position(self, completion=False, help_req=False,
+ valid_python_variable=True):
"""
- 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.
+ Return current word, i.e. word at cursor position, and the start
+ position.
"""
- # 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)
+ 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:
- # 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)
+ # 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]
- 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)
+ if completion:
+ text = text[:cursor_pos - startpos]
- def _selectedBlocks(self):
- """Return selected blocks and tuple (startBlock, endBlock)
- """
- cursor = self.textCursor()
- return self.document().findBlock(cursor.selectionStart()), \
- self.document().findBlock(cursor.selectionEnd())
+ return text, startpos
- def _selectedBlockNumbers(self):
- """Return selected block numbers and tuple (startBlockNumber, endBlockNumber)
- """
- startBlock, endBlock = self._selectedBlocks()
- return startBlock.blockNumber(), endBlock.blockNumber()
+ 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
+ )
- 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()
+ if ret is not None:
+ return ret[0]
+ return None
- 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)
+class EdgeLine(QWidget):
+ def __init__(self, editor):
+ QWidget.__init__(self, editor)
+ self.__editor = editor
+ self.setAttribute(Qt.WA_TransparentForMouseEvents)
- if down: # move next block up
- blockToMove = endBlock.next()
- if not blockToMove.isValid():
- return
+ def paintEvent(self, event):
+ painter = QPainter(self)
+ painter.fillRect(event.rect(), self.__editor.lineLengthEdgeColor)
- # if operaiton is UnDone, marks are located incorrectly
- markMargin = self.getMargin("mark_area")
- if markMargin:
- markMargin.clearBookmarks(startBlock, endBlock.next())
- _moveBlock(blockToMove, startBlockNumber)
+class LineNumberArea(QWidget):
+ _LEFT_MARGIN = 5
+ _RIGHT_MARGIN = 5
- self._selectLines(startBlockNumber + 1, endBlockNumber + 1)
- else: # move previous block down
- blockToMove = startBlock.previous()
- if not blockToMove.isValid():
- return
+ 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)
- # if operaiton is UnDone, marks are located incorrectly
- markMargin = self.getMargin("mark_area")
- if markMargin:
- markMargin.clearBookmarks(startBlock, endBlock)
+ self._editor = parent
+ self._name = 'line_numbers'
+ self._bit_count = 0
+ self._bitRange = None
+ self.__allocateBits()
- _moveBlock(blockToMove, endBlockNumber)
+ self._countCache = (-1, -1)
+ self._editor.updateRequest.connect(self.__updateRequest)
- self._selectLines(startBlockNumber - 1, endBlockNumber - 1)
+ self.__width = self.__calculateWidth()
+ self._editor.blockCountChanged.connect(self.__updateWidth)
- if markMargin:
- markMargin.update()
+ def __updateWidth(self, newBlockCount=None):
+ newWidth = self.__calculateWidth()
+ if newWidth != self.__width:
+ self.__width = newWidth
+ self._editor.updateViewport()
- def _selectedLinesSlice(self):
- """Get slice of selected lines
+ def paintEvent(self, event):
+ """QWidget.paintEvent() implementation
"""
- startBlockNumber, endBlockNumber = self._selectedBlockNumbers()
- return slice(startBlockNumber, endBlockNumber + 1, 1)
-
- def _onShortcutDeleteLine(self):
- """Delete line(s) under cursor
+ 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
"""
- del self.lines[self._selectedLinesSlice()]
+ return self.__width
- def _onShortcutCopyLine(self):
- """Copy selected lines to the clipboard
- """
- lines = self.lines[self._selectedLinesSlice()]
- text = self._eol.join(lines)
- QApplication.clipboard().setText(text)
+ def setFont(self, font):
+ super().setFont(font)
+ self.__updateWidth()
- def _onShortcutPasteLine(self):
- """Paste lines from the clipboard
+ def __allocateBits(self):
+ """Allocates the bit range depending on the required bit count
"""
- lines = self.lines[self._selectedLinesSlice()]
- text = QApplication.clipboard().text()
- if text:
- with self:
- if self.textCursor().hasSelection():
- startBlockNumber, endBlockNumber = 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)
+ 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)
- def _onShortcutCutLine(self):
- """Cut selected lines to the clipboard
+ 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
"""
- lines = self.lines[self._selectedLinesSlice()]
+ if dy:
+ self.scroll(0, dy)
+ elif self._countCache[0] != self._editor.blockCount() or \
+ self._countCache[1] != self._editor.textCursor().block().lineCount():
- self._onShortcutCopyLine()
- self._onShortcutDeleteLine()
+ # if block height not added to rect, last line number sometimes is not drawn
+ blockHeight = self._editor.blockBoundingRect(self._editor.firstVisibleBlock()).height()
- 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.update(0, rect.y(), self.width(), rect.height() + round(blockHeight))
+ self._countCache = (
+ self._editor.blockCount(), self._editor.textCursor().block().lineCount())
- self._updateExtraSelections() # newly inserted text might be highlighted as braces
+ if rect.contains(self._editor.viewport().rect()):
+ self._editor.updateViewportMargins()
- def _onShortcutPrint(self):
- """Ctrl+P handler.
- Show dialog, print file
+ def getName(self):
+ """Provides the margin identifier
"""
- dialog = QPrintDialog(self)
- if dialog.exec_() == QDialog.Accepted:
- printer = dialog.printer()
- self.print_(printer)
+ return self._name
- def _onCompletion(self):
- """Ctrl+Space handler.
- Invoke completer if so configured
+ def getBitRange(self):
+ """None or inclusive bits used pair,
+ e.g. (2,4) => 3 bits used 2nd, 3rd and 4th
"""
- if self._completer:
- self._completer.invokeCompletion()
+ return self._bitRange
- def insertFromMimeData(self, source):
- pass # suppress docstring for non-public method
- if source.hasFormat(self._rectangularSelection.MIME_TYPE):
- self._rectangularSelection.paste(source)
+ 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:
- super(Qutepart, self).insertFromMimeData(source)
-
- def __cursorRect(self, block, column, offset):
- cursor = QTextCursor(block)
- setPositionInBlock(cursor, column)
- return self.cursorRect(cursor).translated(offset, 0)
-
- def getMargins(self):
- """Provides the list of margins
+ 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.
"""
- return self._margins
+ 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 addMargin(self, margin, index=None):
- """Adds a new margin.
- index: index in the list of margins. Default: to the end of the list
+ def show(self):
+ """Override the QWidget::show() method to properly recalculate the
+ editor viewport.
"""
- if index is None:
- self._margins.append(margin)
- else:
- self._margins.insert(index, margin)
- if margin.isVisible():
- self.updateViewport()
+ if self.isHidden():
+ super().show()
+ self._editor.updateViewport()
- def getMargin(self, name):
- """Provides the requested margin.
- Returns a reference to the margin if found and None otherwise
+ def setVisible(self, val):
+ """Override the QWidget::setVisible(bool) method to properly
+ recalculate the editor viewport.
"""
- for margin in self._margins:
- if margin.getName() == name:
- return margin
- return None
+ if val != self.isVisible():
+ if val:
+ super().setVisible(True)
+ else:
+ super().setVisible(False)
+ self._editor.updateViewport()
- def delMargin(self, name):
- """Deletes a margin.
- Returns True if the margin was deleted and False otherwise.
+ # Convenience methods
+
+ def clear(self):
+ """Convenience method to reset all the block values to 0
"""
- for index, margin in enumerate(self._margins):
- if margin.getName() == name:
- visible = margin.isVisible()
- margin.clear()
- margin.deleteLater()
- del self._margins[index]
- if visible:
- self.updateViewport()
- return True
- return False
+ 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()
-def iterateBlocksFrom(block):
- """Generator, which iterates QTextBlocks from block until the End of a document
- """
- while block.isValid():
- yield block
- block = block.next()
+ # Methods for 1-bit margins
+ def isBlockMarked(self, block):
+ return self.getBlockValue(block) != 0
-def iterateBlocksBackFrom(block):
- """Generator, which iterates QTextBlocks from block until the Start of a document
- """
- while block.isValid():
- yield block
- block = block.previous()
+ def toggleBlockMark(self, block):
+ self.setBlockValue(block, 0 if self.isBlockMarked(block) else 1)
diff --git a/Orange/widgets/data/utils/pythoneditor/htmldelegate.py b/Orange/widgets/data/utils/pythoneditor/htmldelegate.py
deleted file mode 100644
index 81131221b99..00000000000
--- a/Orange/widgets/data/utils/pythoneditor/htmldelegate.py
+++ /dev/null
@@ -1,93 +0,0 @@
-"""
-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.
-"""
-"""
-htmldelegate --- QStyledItemDelegate delegate. Draws HTML
-=========================================================
-"""
-
-from PyQt5.QtWidgets import QApplication, QStyle, QStyledItemDelegate, \
- QStyleOptionViewItem
-from PyQt5.QtGui import QAbstractTextDocumentLayout, \
- QTextDocument, QPalette
-from PyQt5.QtCore import QSize
-
-_HTML_ESCAPE_TABLE = \
-{
- "&": "&",
- '"': """,
- "'": "'",
- ">": ">",
- "<": "<",
- " ": " ",
- "\t": " ",
-}
-
-
-def htmlEscape(text):
- """Replace special HTML symbols with escase sequences
- """
- return "".join(_HTML_ESCAPE_TABLE.get(c,c) for c in text)
-
-
-class HTMLDelegate(QStyledItemDelegate):
- """QStyledItemDelegate implementation. Draws HTML
-
- http://stackoverflow.com/questions/1956542/how-to-make-item-view-render-rich-html-text-in-qt/1956781#1956781
- """
-
- def paint(self, painter, option, index):
- """QStyledItemDelegate.paint implementation
- """
- option.state &= ~QStyle.State_HasFocus # never draw focus rect
-
- options = QStyleOptionViewItem(option)
- self.initStyleOption(options,index)
-
- style = QApplication.style() if options.widget is None else options.widget.style()
-
- doc = QTextDocument()
- doc.setDocumentMargin(1)
- doc.setHtml(options.text)
- if options.widget is not None:
- doc.setDefaultFont(options.widget.font())
- # bad long (multiline) strings processing doc.setTextWidth(options.rect.width())
-
- options.text = ""
- style.drawControl(QStyle.CE_ItemViewItem, options, painter);
-
- ctx = QAbstractTextDocumentLayout.PaintContext()
-
- # Highlighting text if item is selected
- if option.state & QStyle.State_Selected:
- ctx.palette.setColor(QPalette.Text, option.palette.color(QPalette.Active, QPalette.HighlightedText))
-
- textRect = style.subElementRect(QStyle.SE_ItemViewItemText, options)
- painter.save()
- painter.translate(textRect.topLeft())
- """Original example contained line
- painter.setClipRect(textRect.translated(-textRect.topLeft()))
- but text is drawn clipped with it on kubuntu 12.04
- """
- doc.documentLayout().draw(painter, ctx)
-
- painter.restore()
-
- def sizeHint(self, option, index):
- """QStyledItemDelegate.sizeHint implementation
- """
- options = QStyleOptionViewItem(option)
- self.initStyleOption(options,index)
-
- doc = QTextDocument()
- doc.setDocumentMargin(1)
- # bad long (multiline) strings processing doc.setTextWidth(options.rect.width())
- doc.setHtml(options.text)
- return QSize(doc.idealWidth(),
- QStyledItemDelegate.sizeHint(self, option, index).height())
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/indenter/__init__.py b/Orange/widgets/data/utils/pythoneditor/indenter/__init__.py
deleted file mode 100644
index 7b8744e6449..00000000000
--- a/Orange/widgets/data/utils/pythoneditor/indenter/__init__.py
+++ /dev/null
@@ -1,242 +0,0 @@
-"""
-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.
-"""
-"""Module computes indentation for block
-It contains implementation of indenters, which are supported by katepart xml files
-"""
-
-import logging
-
-logger = logging.getLogger('qutepart')
-
-
-from PyQt5.QtGui import QTextCursor
-
-
-def _getSmartIndenter(indenterName, qpart, indenter):
- """Get indenter by name.
- Available indenters are none, normal, cstyle, haskell, lilypond, lisp, python, ruby, xml
- Indenter name is not case sensitive
- Raise KeyError if not found
- indentText is indentation, which shall be used. i.e. '\t' for tabs, ' ' for 4 space symbols
- """
- indenterName = indenterName.lower()
-
- if indenterName in ('haskell', 'lilypond'): # not supported yet
- logger.warning('Smart indentation for %s not supported yet. But you could be a hero who implemented it' % indenterName)
- from qutepart.indenter.base import IndentAlgNormal as indenterClass
- elif 'none' == indenterName:
- from qutepart.indenter.base import IndentAlgBase as indenterClass
- elif 'normal' == indenterName:
- from qutepart.indenter.base import IndentAlgNormal as indenterClass
- elif 'cstyle' == indenterName:
- from qutepart.indenter.cstyle import IndentAlgCStyle as indenterClass
- elif 'python' == indenterName:
- from qutepart.indenter.python import IndentAlgPython as indenterClass
- elif 'ruby' == indenterName:
- from qutepart.indenter.ruby import IndentAlgRuby as indenterClass
- elif 'xml' == indenterName:
- from qutepart.indenter.xmlindent import IndentAlgXml as indenterClass
- elif 'haskell' == indenterName:
- from qutepart.indenter.haskell import IndenterHaskell as indenterClass
- elif 'lilypond' == indenterName:
- from qutepart.indenter.lilypond import IndenterLilypond as indenterClass
- elif 'lisp' == indenterName:
- from qutepart.indenter.lisp import IndentAlgLisp as indenterClass
- elif 'scheme' == indenterName:
- from qutepart.indenter.scheme import IndentAlgScheme as indenterClass
- else:
- raise KeyError("Indenter %s not found" % indenterName)
-
- return indenterClass(qpart, indenter)
-
-
-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 = _getSmartIndenter('normal', self._qpart, self)
-
- def setSyntax(self, syntax):
- """Choose smart indentation algorithm according to syntax"""
- self._smartIndenter = self._chooseSmartIndenter(syntax)
-
- 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()):
- endBlock = endBlock.previous() # do not indent not selected line if indenting multiple lines
-
- 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, '')
-
- def _chooseSmartIndenter(self, syntax):
- """Get indenter for syntax
- """
- if syntax.indenter is not None:
- try:
- return _getSmartIndenter(syntax.indenter, self._qpart, self)
- except KeyError:
- logger.error("Indenter '%s' is not finished yet. But you can do it!" % syntax.indenter)
-
- try:
- return _getSmartIndenter(syntax.name, self._qpart, self)
- except KeyError:
- pass
-
- return _getSmartIndenter('normal', self._qpart, self)
diff --git a/Orange/widgets/data/utils/pythoneditor/indenter/base.py b/Orange/widgets/data/utils/pythoneditor/indenter/base.py
deleted file mode 100644
index ac189c5d874..00000000000
--- a/Orange/widgets/data/utils/pythoneditor/indenter/base.py
+++ /dev/null
@@ -1,297 +0,0 @@
-"""
-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.
-"""
-# maximum number of lines we look backwards/forward to find out the indentation
-# level (the bigger the number, the longer might be the delay)
-MAX_SEARCH_OFFSET_LINES = 128
-
-
-class IndentAlgNone:
- """No any indentation
- """
- def __init__(self, qpart):
- pass
-
- def computeSmartIndent(self, block, char):
- return ''
-
-
-class IndentAlgBase(IndentAlgNone):
- """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 NotImplemented()
-
- 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 block in cls.iterateBlocksBackFrom(block):
- for index, char in enumerate(reversed(block.text())):
- yield block, len(block.text()) - index - 1, char
-
- def findBracketBackward(self, block, column, bracket):
- """Search for a needle and return (block, column)
- Raise ValueError, if not found
-
- NOTE this method ignores comments
- """
- 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
- else:
- 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.keys():
- opening, closing = brackets
- if char == opening:
- depth[brackets] -= 1
- if depth[brackets] == 0:
- return foundBlock, foundColumn
- elif char == closing:
- depth[brackets] += 1
- else:
- 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
-
- def _lastColumn(self, block):
- """Returns the last non-whitespace column in the given line.
- If there are only whitespaces in the line, the return value is -1.
- """
- text = block.text()
- index = len(block.text()) - 1
- while index >= 0 and \
- (text[index].isspace() or \
- self._qpart.isComment(block.blockNumber(), index)):
- index -= 1
-
- return index
-
- @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 IndentAlgNormal(IndentAlgBase):
- """Class automatically computes indentation for lines
- This is basic indenter, which knows nothing about programming languages
- """
- def computeSmartIndent(self, block, char):
- return self._prevNonEmptyBlockIndent(block)
diff --git a/Orange/widgets/data/utils/pythoneditor/indenter/cstyle.py b/Orange/widgets/data/utils/pythoneditor/indenter/cstyle.py
deleted file mode 100644
index 7dedaaa1485..00000000000
--- a/Orange/widgets/data/utils/pythoneditor/indenter/cstyle.py
+++ /dev/null
@@ -1,644 +0,0 @@
-"""
-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
-
-from qutepart.indenter.base import IndentAlgBase
-
-# User configuration
-CFG_INDENT_CASE = True # indent 'case' and 'default' in a switch?
-CFG_INDENT_NAMESPACE = True # indent after 'namespace'?
-CFG_AUTO_INSERT_STAR = True # auto insert '*' in C-comments
-CFG_SNAP_SLASH = True # snap '/' to '*/' in C-comments
-CFG_AUTO_INSERT_SLACHES = False # auto insert '//' after C++-comments
-CFG_ACCESS_MODIFIERS = 1 # indent level of access modifiers, relative to the class indent level
- # set to -1 to disable auto-indendation after access modifiers.
-
-# indent gets three arguments: line, indentwidth in spaces, typed character
-# indent
-
-# specifies the characters which should trigger indent, beside the default '\n'
-
-DEBUG_MODE = False
-
-def dbg(*args):
- if (DEBUG_MODE):
- print(args)
-
-#global variables and functions
-
-INDENT_WIDTH = 4
-MODE = "C"
-
-
-class IndentAlgCStyle(IndentAlgBase):
- TRIGGER_CHARACTERS = "{})/:;#"
-
- @staticmethod
- def _prevNonEmptyBlock(block):
- """Reimplemented base indenter level. Skips comments
- """
- block = block.previous()
- while block.isValid() and \
- (len(block.text().strip()) == 0 or \
- block.text().startswith('//') or \
- block.text().startswith('#')):
- block = block.previous()
-
- return block
-
- def findTextBackward(self, block, column, needle):
- """Search for a needle and return (block, column)
- Raise ValueError, if not found
- """
- if column is not None:
- index = block.text()[:column].rfind(needle)
- else:
- index = block.text().rfind(needle)
-
- if index != -1:
- return block, index
-
- for block in self.iterateBlocksBackFrom(block.previous()):
- column = block.text().rfind(needle)
- if column != -1:
- return block, column
-
- raise ValueError('Not found')
-
- def findLeftBrace(self, block, column):
- """Search for a corresponding '{' and return its indentation
- If not found return None
- """
- block, column = self.findBracketBackward(block, column, '{') # raise ValueError if not found
-
- try:
- block, column = self.tryParenthesisBeforeBrace(block, column)
- except ValueError:
- pass # leave previous values
- return self._blockIndent(block)
-
- def tryParenthesisBeforeBrace(self, block, column):
- """ Character at (block, column) has to be a '{'.
- Now try to find the right line for indentation for constructs like:
- if (a == b
- and c == d) { <- check for ')', and find '(', then return its indentation
- Returns input params, if no success, otherwise block and column of '('
- """
- text = block.text()[:column - 1].rstrip()
- if not text.endswith(')'):
- raise ValueError()
- return self.findBracketBackward(block, len(text) - 1, '(')
-
- def trySwitchStatement(self, block):
- """Check for default and case keywords and assume we are in a switch statement.
- Try to find a previous default, case or switch and return its indentation or
- None if not found.
- """
- if not re.match(r'^\s*(default\s*|case\b.*):', block.text()):
- return None
-
- for block in self.iterateBlocksBackFrom(block.previous()):
- text = block.text()
- if re.match(r"^\s*(default\s*|case\b.*):", text):
- dbg("trySwitchStatement: success in line %d" % block.blockNumber())
- return self._lineIndent(text)
- elif re.match(r"^\s*switch\b", text):
- if CFG_INDENT_CASE:
- return self._increaseIndent(self._lineIndent(text))
- else:
- return self._lineIndent(text)
-
- return None
-
- def tryAccessModifiers(self, block):
- """Check for private, protected, public, signals etc... and assume we are in a
- class definition. Try to find a previous private/protected/private... or
- class and return its indentation or null if not found.
- """
-
- if CFG_ACCESS_MODIFIERS < 0:
- return None
-
- if not re.match(r'^\s*((public|protected|private)\s*(slots|Q_SLOTS)?|(signals|Q_SIGNALS)\s*):\s*$', block.text()):
- return None
-
- try:
- block, notUsedColumn = self.findBracketBackward(block, 0, '{')
- except ValueError:
- return None
-
- indentation = self._blockIndent(block)
- for i in range(CFG_ACCESS_MODIFIERS):
- indentation = self._increaseIndent(indentation)
-
- dbg("tryAccessModifiers: success in line %d" % block.blockNumber())
- return indentation
-
- def tryCComment(self, block):
- """C comment checking. If the previous line begins with a "/*" or a "* ", then
- return its leading white spaces + ' *' + the white spaces after the *
- return: filler string or null, if not in a C comment
- """
- indentation = None
-
- prevNonEmptyBlock = self._prevNonEmptyBlock(block)
- if not prevNonEmptyBlock.isValid():
- return None
-
- prevNonEmptyBlockText = prevNonEmptyBlock.text()
-
- if prevNonEmptyBlockText.endswith('*/'):
- try:
- foundBlock, notUsedColumn = self.findTextBackward(prevNonEmptyBlock, prevNonEmptyBlock.length(), '/*')
- except ValueError:
- foundBlock = None
-
- if foundBlock is not None:
- dbg("tryCComment: success (1) in line %d" % foundBlock.blockNumber())
- return self._lineIndent(foundBlock.text())
-
- if prevNonEmptyBlock != block.previous():
- # inbetween was an empty line, so do not copy the "*" character
- return None
-
- blockTextStripped = block.text().strip()
- prevBlockTextStripped = prevNonEmptyBlockText.strip()
-
- if prevBlockTextStripped.startswith('/*') and not '*/' in prevBlockTextStripped:
- indentation = self._blockIndent(prevNonEmptyBlock)
- if CFG_AUTO_INSERT_STAR:
- # only add '*', if there is none yet.
- indentation += ' '
- if not blockTextStripped.endswith('*'):
- indentation += '*'
- secondCharIsSpace = len(blockTextStripped) > 1 and blockTextStripped[1].isspace()
- if not secondCharIsSpace and \
- not blockTextStripped.endswith("*/"):
- indentation += ' '
- dbg("tryCComment: success (2) in line %d" % block.blockNumber())
- return indentation
-
- elif prevBlockTextStripped.startswith('*') and \
- (len(prevBlockTextStripped) == 1 or prevBlockTextStripped[1].isspace()):
-
- # in theory, we could search for opening /*, and use its indentation
- # and then one alignment character. Let's not do this for now, though.
- indentation = self._lineIndent(prevNonEmptyBlockText)
- # only add '*', if there is none yet.
- if CFG_AUTO_INSERT_STAR and not blockTextStripped.startswith('*'):
- indentation += '*'
- if len(blockTextStripped) < 2 or not blockTextStripped[1].isspace():
- indentation += ' '
-
- dbg("tryCComment: success (2) in line %d" % block.blockNumber())
- return indentation
-
- return None
-
- def tryCppComment(self, block):
- """C++ comment checking. when we want to insert slashes:
- #, #/, #! #/<, #!< and ##...
- return: filler string or null, if not in a star comment
- NOTE: otherwise comments get skipped generally and we use the last code-line
- """
- if not block.previous().isValid() or \
- not CFG_AUTO_INSERT_SLACHES:
- return None
-
- prevLineText = block.previous().text()
-
- indentation = None
- comment = prevLineText.lstrip().startswith('#')
-
- # allowed are: #, #/, #! #/<, #!< and ##...
- if comment:
- prevLineText = block.previous().text()
- lstrippedText = block.previous().text().lstrip()
- if len(lstrippedText) >= 4:
- char3 = lstrippedText[2]
- char4 = lstrippedText[3]
-
- indentation = self._lineIndent(prevLineText)
-
- if CFG_AUTO_INSERT_SLACHES:
- if prevLineText[2:4] == '//':
- # match ##... and replace by only two: #
- match = re.match(r'^\s*(\/\/)', prevLineText)
- elif (char3 == '/' or char3 == '!'):
- # match #/, #!, #/< and #!
- match = re.match(r'^\s*(\/\/[\/!][<]?\s*)', prevLineText)
- else:
- # only #, nothing else:
- match = re.match(r'^\s*(\/\/\s*)', prevLineText)
-
- if match is not None:
- self._qpart.insertText((block.blockNumber(), 0), match.group(1))
-
- if indentation is not None:
- dbg("tryCppComment: success in line %d" % block.previous().blockNumber())
-
- return indentation
-
- def tryBrace(self, block):
- def _isNamespace(block):
- if not block.text().strip():
- block = block.previous()
-
- return re.match(r'^\s*namespace\b', block.text()) is not None
-
- currentBlock = self._prevNonEmptyBlock(block)
- if not currentBlock.isValid():
- return None
-
- indentation = None
-
- if currentBlock.text().rstrip().endswith('{'):
- try:
- foundBlock, notUsedColumn = self.tryParenthesisBeforeBrace(currentBlock, len(currentBlock.text().rstrip()))
- except ValueError: # not found
- indentation = self._blockIndent(currentBlock)
- if CFG_INDENT_NAMESPACE or not _isNamespace(block):
- # take its indentation and add one indentation level
- indentation = self._increaseIndent(indentation)
- else: # found
- indentation = self._increaseIndent(self._blockIndent(foundBlock))
-
-
- if indentation is not None:
- dbg("tryBrace: success in line %d" % block.blockNumber())
- return indentation
-
- def tryCKeywords(self, block, isBrace):
- """
- Check for if, else, while, do, switch, private, public, protected, signals,
- default, case etc... keywords, as we want to indent then. If is
- non-null/True, then indentation is not increased.
- Note: The code is written to be called *after* tryCComment and tryCppComment!
- """
- currentBlock = self._prevNonEmptyBlock(block)
- if not currentBlock.isValid():
- return None
-
- # if line ends with ')', find the '(' and check this line then.
-
- if currentBlock.text().rstrip().endswith(')'):
- try:
- foundBlock, foundColumn = self.findBracketBackward(currentBlock, len(currentBlock.text()), '(')
- except ValueError:
- pass
- else:
- currentBlock = foundBlock
-
- # found non-empty line
- currentBlockText = currentBlock.text()
- if re.match(r'^\s*(if\b|for|do\b|while|switch|[}]?\s*else|((private|public|protected|case|default|signals|Q_SIGNALS).*:))', currentBlockText) is None:
- return None
-
- indentation = None
-
- # ignore trailing comments see: https:#bugs.kde.org/show_bug.cgi?id=189339
- try:
- index = currentBlockText.index('//')
- except ValueError:
- pass
- else:
- currentBlockText = currentBlockText[:index]
-
- # try to ignore lines like: if (a) b; or if (a) { b; }
- if not currentBlockText.endswith(';') and \
- not currentBlockText.endswith('}'):
- # take its indentation and add one indentation level
- indentation = self._lineIndent(currentBlockText)
- if not isBrace:
- indentation = self._increaseIndent(indentation)
- elif currentBlockText.endswith(';'):
- # stuff like:
- # for(int b;
- # b < 10;
- # --b)
- try:
- foundBlock, foundColumn = self.findBracketBackward(currentBlock, None, '(')
- except ValueError:
- pass
- else:
- dbg("tryCKeywords: success 1 in line %d" % block.blockNumber())
- return self._makeIndentAsColumn(foundBlock, foundColumn, 1)
- if indentation is not None:
- dbg("tryCKeywords: success in line %d" % block.blockNumber())
-
- return indentation
-
- def tryCondition(self, block):
- """ Search for if, do, while, for, ... as we want to indent then.
- Return null, if nothing useful found.
- Note: The code is written to be called *after* tryCComment and tryCppComment!
- """
- currentBlock = self._prevNonEmptyBlock(block)
- if not currentBlock.isValid():
- return None
-
- # found non-empty line
- currentText = currentBlock.text()
- if currentText.rstrip().endswith(';') and \
- re.search(r'^\s*(if\b|[}]?\s*else|do\b|while\b|for)', currentText) is None:
- # idea: we had something like:
- # if/while/for (expression)
- # statement(); <-- we catch this trailing ';'
- # Now, look for a line that starts with if/for/while, that has one
- # indent level less.
- currentIndentation = self._lineIndent(currentText)
- if not currentIndentation:
- return None
-
- for block in self.iterateBlocksBackFrom(currentBlock.previous()):
- if block.text().strip(): # not empty
- indentation = self._blockIndent(block)
-
- if len(indentation) < len(currentIndentation):
- if re.search(r'^\s*(if\b|[}]?\s*else|do\b|while\b|for)[^{]*$', block.text()) is not None:
- dbg("tryCondition: success in line %d" % block.blockNumber())
- return indentation
- break
-
- return None
-
- def tryStatement(self, block):
- """ If the non-empty line ends with ); or ',', then search for '(' and return its
- indentation; also try to ignore trailing comments.
- """
- currentBlock = self._prevNonEmptyBlock(block)
-
- if not currentBlock.isValid():
- return None
-
- indentation = None
-
- currentBlockText = currentBlock.text()
- if currentBlockText.endswith('('):
- # increase indent level
- dbg("tryStatement: success 1 in line %d" % block.blockNumber())
- return self._increaseIndent(self._lineIndent(currentBlockText))
-
- alignOnSingleQuote = self._qpart.language() in ('PHP/PHP', 'JavaScript')
- # align on strings "..."\n => below the opening quote
- # multi-language support: [\.+] for javascript or php
- pattern = '^(.*)' # any group 1
- pattern += '([,"\'\\)])' # one of [ , " ' ) group 2
- pattern += '(;?)' # optional ; group 3
- pattern += '\s*[\.+]?\s*' # optional spaces optional . or + optional spaces
- pattern += '(//.*|/\\*.*\\*/\s*)?$' # optional(//any or /*any*/spaces) group 4
- match = re.match(pattern, currentBlockText)
- if match is not None:
- alignOnAnchor = len(match.group(3)) == 0 and match.group(2) != ')'
- # search for opening ", ' or (
- if match.group(2) == '"' or (alignOnSingleQuote and match.group(2) == "'"):
- startIndex = len(match.group(1))
- while True:
- # start from matched closing ' or "
- # find string opener
- for i in range(startIndex - 1, 0, -1):
- # make sure it's not commented out
- if currentBlockText[i] == match.group(2) and (i == 0 or currentBlockText[i - 1] != '\\'):
- # also make sure that this is not a line like '#include "..."' <-- we don't want to indent here
- if re.match(r'^#include', currentBlockText):
- dbg("tryStatement: success 2 in line %d" % block.blockNumber())
- return indentation
-
- break
-
- if not alignOnAnchor and currentBlock.previous().isValid():
- # when we finished the statement (;) we need to get the first line and use it's indentation
- # i.e.: $foo = "asdf"; -> align on $
- i -= 1 # skip " or '
- # skip whitespaces and stuff like + or . (for PHP, JavaScript, ...)
- for i in range(i, 0, -1):
- if currentBlockText[i] in (' ', '\t', '.', '+'):
- continue
- else:
- break
-
- if i > 0:
- # there's something in this line, use it's indentation
- break
- else:
- # go to previous line
- currentBlock = currentBlock.previous()
- currentBlockText = currentBlock.text()
- startIndex = len(currentBlockText)
- else:
- break
-
- elif match.group(2) == ',' and not '(' in currentBlockText:
- # assume a function call: check for '(' brace
- # - if not found, use previous indentation
- # - if found, compare the indentation depth of current line and open brace line
- # - if current indentation depth is smaller, use that
- # - otherwise, use the '(' indentation + following white spaces
- currentIndentation = self._blockIndent(currentBlock)
- try:
- foundBlock, foundColumn = self.findBracketBackward(currentBlock, len(match.group(1)), '(')
- except ValueError:
- indentation = currentIndentation
- else:
- indentWidth = foundColumn + 1
- text = foundBlock.text()
- while indentWidth < len(text) and text[indentWidth].isspace():
- indentWidth += 1
- indentation = self._makeIndentAsColumn(foundBlock, indentWidth)
-
- else:
- try:
- foundBlock, foundColumn = self.findBracketBackward(currentBlock, len(match.group(1)), '(')
- except ValueError:
- pass
- else:
- if alignOnAnchor:
- if not match.group(2) in ('"', "'"):
- foundColumn += 1
- foundBlockText = foundBlock.text()
- while foundColumn < len(foundBlockText) and \
- foundBlockText[foundColumn].isspace():
- foundColumn += 1
- indentation = self._makeIndentAsColumn(foundBlock, foundColumn)
- else:
- currentBlock = foundBlock
- indentation = self._blockIndent(currentBlock)
- elif currentBlockText.rstrip().endswith(';'):
- indentation = self._blockIndent(currentBlock)
-
- if indentation is not None:
- dbg("tryStatement: success in line %d" % currentBlock.blockNumber())
- return indentation
-
- def tryMatchedAnchor(self, block, autoIndent):
- """
- find out whether we pressed return in something like {} or () or [] and indent properly:
- {}
- becomes:
- {
- |
- }
- """
- oposite = { ')': '(',
- '}': '{',
- ']': '['}
-
- char = self._firstNonSpaceChar(block)
- if not char in oposite.keys():
- return None
-
- # we pressed enter in e.g. ()
- try:
- foundBlock, foundColumn = self.findBracketBackward(block, 0, oposite[char])
- except ValueError:
- return None
-
- if autoIndent:
- # when aligning only, don't be too smart and just take the indent level of the open anchor
- return self._blockIndent(foundBlock)
-
- lastChar = self._lastNonSpaceChar(block.previous())
- charsMatch = ( lastChar == '(' and char == ')' ) or \
- ( lastChar == '{' and char == '}' ) or \
- ( lastChar == '[' and char == ']' )
-
- indentation = None
- if (not charsMatch) and char != '}':
- # otherwise check whether the last line has the expected
- # indentation, if not use it instead and place the closing
- # anchor on the level of the opening anchor
- expectedIndentation = self._increaseIndent(self._blockIndent(foundBlock))
- actualIndentation = self._increaseIndent(self._blockIndent(block.previous()))
- indentation = None
- if len(expectedIndentation) <= len(actualIndentation):
- if lastChar == ',':
- # use indentation of last line instead and place closing anchor
- # in same column of the opening anchor
- self._qpart.insertText((block.blockNumber(), self._firstNonSpaceColumn(block.text())), '\n')
- self._qpart.cursorPosition = (block.blockNumber(), len(actualIndentation))
- # indent closing anchor
- self._setBlockIndent(block.next(), self._makeIndentAsColumn(foundBlock, foundColumn))
- indentation = actualIndentation
- elif expectedIndentation == self._blockIndent(block.previous()):
- # otherwise don't add a new line, just use indentation of closing anchor line
- indentation = self._blockIndent(foundBlock)
- else:
- # otherwise don't add a new line, just align on closing anchor
- indentation = self._makeIndentAsColumn(foundBlock, foundColumn)
-
- dbg("tryMatchedAnchor: success in line %d" % foundBlock.blockNumber())
- return indentation
-
- # otherwise we i.e. pressed enter between (), [] or when we enter before curly brace
- # increase indentation and place closing anchor on the next line
- indentation = self._blockIndent(foundBlock)
- self._qpart.replaceText((block.blockNumber(), 0), len(self._blockIndent(block)), "\n")
- self._qpart.cursorPosition = (block.blockNumber(), len(indentation))
- # indent closing brace
- self._setBlockIndent(block.next(), indentation)
- dbg("tryMatchedAnchor: success in line %d" % foundBlock.blockNumber())
- return self._increaseIndent(indentation)
-
- def indentLine(self, block, autoIndent):
- """ Indent line.
- Return filler or null.
- """
- indent = None
- if indent is None:
- indent = self.tryMatchedAnchor(block, autoIndent)
- if indent is None:
- indent = self.tryCComment(block)
- if indent is None and not autoIndent:
- indent = self.tryCppComment(block)
- if indent is None:
- indent = self.trySwitchStatement(block)
- if indent is None:
- indent = self.tryAccessModifiers(block)
- if indent is None:
- indent = self.tryBrace(block)
- if indent is None:
- indent = self.tryCKeywords(block, block.text().lstrip().startswith('{'))
- if indent is None:
- indent = self.tryCondition(block)
- if indent is None:
- indent = self.tryStatement(block)
-
- if indent is not None:
- return indent
- else:
- dbg("Nothing matched")
- return self._prevNonEmptyBlockIndent(block)
-
- def processChar(self, block, c):
- if c == ';' or (not (c in self.TRIGGER_CHARACTERS)):
- return self._blockIndent(block)
-
- column = self._qpart.cursorPosition[1]
- blockIndent = self._blockIndent(block)
- firstCharAfterIndent = column == (len(blockIndent) + 1)
-
- if firstCharAfterIndent and c == '{':
- # todo: maybe look for if etc.
- indent = self.tryBrace(block)
- if indent is None:
- indent = self.tryCKeywords(block, True)
- if indent is None:
- indent = self.tryCComment(block); # checks, whether we had a "*/"
- if indent is None:
- indent = self.tryStatement(block)
- if indent is None:
- indent = blockIndent
-
- return indent
- elif firstCharAfterIndent and c == '}':
- try:
- indentation = self.findLeftBrace(block, self._firstNonSpaceColumn(block.text()))
- except ValueError:
- return blockIndent
- else:
- return indentation
- elif CFG_SNAP_SLASH and c == '/' and block.text().endswith(' /'):
- # try to snap the string "* /" to "*/"
- match = re.match(r'^(\s*)\*\s+\/\s*$', block.text())
- if match is not None:
- self._qpart.lines[block.blockNumber()] = match.group(1) + '*/'
- dbg("snapSlash at block %d" % block.blockNumber())
- return blockIndent
- elif c == ':':
- # todo: handle case, default, signals, private, public, protected, Q_SIGNALS
- indent = self.trySwitchStatement(block)
- if indent is None:
- indent = self.tryAccessModifiers(block)
- if indent is None:
- indent = blockIndent
- return indent
- elif c == ')' and firstCharAfterIndent:
- # align on start of identifier of function call
- try:
- foundBlock, foundColumn = self.findBracketBackward(block, column - 1, '(')
- except ValueError:
- pass
- else:
- text = foundBlock.text()[:foundColumn]
- match = re.search(r'\b(\w+)\s*$', text)
- if match is not None:
- return self._makeIndentAsColumn(foundBlock, match.start())
- elif firstCharAfterIndent and c == '#' and self._qpart.language() in ('C', 'C++'):
- # always put preprocessor stuff upfront
- return ''
- return blockIndent
-
- def computeSmartIndent(self, block, char):
- autoIndent = char == ""
-
- if char != '\n' and not autoIndent:
- return self.processChar(block, char)
-
- return self.indentLine(block, autoIndent)
diff --git a/Orange/widgets/data/utils/pythoneditor/indenter/lisp.py b/Orange/widgets/data/utils/pythoneditor/indenter/lisp.py
deleted file mode 100644
index be5dfe10e16..00000000000
--- a/Orange/widgets/data/utils/pythoneditor/indenter/lisp.py
+++ /dev/null
@@ -1,35 +0,0 @@
-"""
-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
-
-from qutepart.indenter.base import IndentAlgBase
-
-class IndentAlgLisp(IndentAlgBase):
- TRIGGER_CHARACTERS = ";"
-
- def computeSmartIndent(self, block, ch):
- """special rules: ;;; -> indent 0
- ;; -> align with next line, if possible
- ; -> usually on the same line as code -> ignore
- """
- if re.search(r'^\s*;;;', block.text()):
- return ''
- elif re.search(r'^\s*;;', block.text()):
- #try to align with the next line
- nextBlock = self._nextNonEmptyBlock(block)
- if nextBlock.isValid():
- return self._blockIndent(nextBlock)
-
- try:
- foundBlock, foundColumn = self.findBracketBackward(block, 0, '(')
- except ValueError:
- return ''
- else:
- return self._increaseIndent(self._blockIndent(foundBlock))
diff --git a/Orange/widgets/data/utils/pythoneditor/indenter/python.py b/Orange/widgets/data/utils/pythoneditor/indenter/python.py
deleted file mode 100644
index aabfc339e76..00000000000
--- a/Orange/widgets/data/utils/pythoneditor/indenter/python.py
+++ /dev/null
@@ -1,107 +0,0 @@
-"""
-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 qutepart.indenter.base import IndentAlgBase
-
-
-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:
- foundBlock, foundColumn = self.findBracketBackward(block,
- spaceLen + len(lineStripped) - 1,
- lineStripped[-1])
- 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/indenter/ruby.py b/Orange/widgets/data/utils/pythoneditor/indenter/ruby.py
deleted file mode 100644
index 5cb788db64b..00000000000
--- a/Orange/widgets/data/utils/pythoneditor/indenter/ruby.py
+++ /dev/null
@@ -1,297 +0,0 @@
-"""
-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 qutepart.indenter.base import IndentAlgBase
-
-import re
-
-# Indent after lines that match this regexp
-rxIndent = re.compile(r'^\s*(def|if|unless|for|while|until|class|module|else|elsif|case|when|begin|rescue|ensure|catch)\b')
-
-# Unindent lines that match this regexp
-rxUnindent = re.compile(r'^\s*((end|when|else|elsif|rescue|ensure)\b|[\]\}])(.*)$')
-
-rxBlockEnd = re.compile(r'\s*end$')
-
-
-class Statement:
- def __init__(self, qpart, startBlock, endBlock):
- self._qpart = qpart
- self.startBlock = startBlock
- self.endBlock = endBlock
-
- # Convert to string for debugging
- def __str__(self):
- return "{ %d, %d}" % (self.startBlock.blockNumber(), self.endBlock.blockNumber())
-
- def offsetToCursor(self, offset):
- # Return (block, column)
- # TODO Provide helper function for this when API is converted to using cursors:
- block = self.startBlock
- while block != self.endBlock.next() and \
- len(block.text()) < offset:
- offset -= len(block.text())
- block = block.next()
-
- return block, offset
-
- def isCode(self, offset):
- # Return document.isCode at the given offset in a statement
- block, column = self.offsetToCursor(offset)
- return self._qpart.isCode(block.blockNumber(), column)
-
- def isComment(self, offset):
- # Return document.isComment at the given offset in a statement
- block, column = self.offsetToCursor(offset)
- return self._qpart.isComment(block.blockNumber(), column)
-
- def indent(self):
- # Return the indent at the beginning of the statement
- return IndentAlgRuby._blockIndent(self.startBlock)
-
- def content(self):
- # Return the content of the statement from the document
- cnt = ""
- block = self.startBlock
- while block != self.endBlock.next():
- text = block.text()
- if text.endswith('\\'):
- cnt += text[:-1]
- cnt += ' '
- else:
- cnt += text
- block = block.next()
- return cnt
-
-
-class IndentAlgRuby(IndentAlgBase):
- """Indenter for Ruby
- """
- TRIGGER_CHARACTERS = "cdefhilnrsuw}]"
-
- def _isCommentBlock(self, block):
- text = block.text()
- firstColumn = self._firstNonSpaceColumn(text)
- return firstColumn == len(text) or self._isComment(block, firstColumn)
-
- def _isComment(self, block, column):
- return self._qpart.isComment(block.blockNumber(), column)
-
- def _prevNonCommentBlock(self, block):
- """Return the closest non-empty line, ignoring comments
- (result <= line). Return -1 if the document
- """
- block = self._prevNonEmptyBlock(block)
- while block.isValid() and self._isCommentBlock(block):
- block = self._prevNonEmptyBlock(block)
- return block
-
- @staticmethod
- def _isBlockContinuing(block):
- return block.text().endswith('\\')
-
- def _isLastCodeColumn(self, block, column):
- """Return true if the given column is at least equal to the column that
- contains the last non-whitespace character at the given line, or if
- the rest of the line is a comment.
- """
- return column >= self._lastColumn(block) or \
- self._isComment(block, self._nextNonSpaceColumn(block, column + 1))
-
- @staticmethod
- def testAtEnd(stmt, rx):
- """Look for a pattern at the end of the statement.
-
- Returns true if the pattern is found, in a position
- that is not inside a string or a comment, and the position +
- the length of the matching part is either the end of the
- statement, or a comment.
-
- The regexp must be global, and the search is continued until
- a match is found, or the end of the string is reached.
- """
- for match in rx.finditer(stmt.content()):
- if stmt.isCode(match.start()):
- if match.end() == len(stmt.content()):
- return True
- if stmt.isComment(match.end()):
- return True
- else:
- return False
-
- def lastAnchor(self, block, column):
- """Find the last open bracket before the current line.
- Return (block, column, char) or (None, None, None)
- """
- currentPos = -1
- currentBlock = None
- currentColumn = None
- currentChar = None
- for char in '({[':
- try:
- foundBlock, foundColumn = self.findBracketBackward(block, column, char)
- except ValueError:
- continue
- else:
- pos = foundBlock.position() + foundColumn
- if pos > currentPos:
- currentBlock = foundBlock
- currentColumn = foundColumn
- currentChar = char
- currentPos = pos
-
- return currentBlock, currentColumn, currentChar
-
- def isStmtContinuing(self, block):
- #Is there an open parenthesis?
-
- foundBlock, foundColumn, foundChar = self.lastAnchor(block, block.length())
- if foundBlock is not None:
- return True
-
- stmt = Statement(self._qpart, block, block)
- rx = re.compile(r'(\+|\-|\*|\/|\=|&&|\|\||\band\b|\bor\b|,)\s*')
- return self.testAtEnd(stmt, rx)
-
- def findStmtStart(self, block):
- """Return the first line that is not preceded by a "continuing" line.
- Return currBlock if currBlock <= 0
- """
- prevBlock = self._prevNonCommentBlock(block)
- while prevBlock.isValid() and \
- (((prevBlock == block.previous()) and self._isBlockContinuing(prevBlock)) or \
- self.isStmtContinuing(prevBlock)):
- block = prevBlock
- prevBlock = self._prevNonCommentBlock(block)
- return block
-
- @staticmethod
- def _isValidTrigger(block, ch):
- """check if the trigger characters are in the right context,
- otherwise running the indenter might be annoying to the user
- """
- if ch == "" or ch == "\n":
- return True # Explicit align or new line
-
- match = rxUnindent.match(block.text())
- return match is not None and \
- match.group(3) == ""
-
- def findPrevStmt(self, block):
- """Returns a tuple that contains the first and last line of the
- previous statement before line.
- """
- stmtEnd = self._prevNonCommentBlock(block)
- stmtStart = self.findStmtStart(stmtEnd)
- return Statement(self._qpart, stmtStart, stmtEnd)
-
- def isBlockStart(self, stmt):
- if rxIndent.search(stmt.content()):
- return True
-
- rx = re.compile(r'((\bdo\b|\{)(\s*\|.*\|)?\s*)')
-
- return self.testAtEnd(stmt, rx)
-
- @staticmethod
- def isBlockEnd(stmt):
- return rxUnindent.match(stmt.content())
-
- def findBlockStart(self, block):
- nested = 0
- stmt = Statement(self._qpart, block, block)
- while True:
- if not stmt.startBlock.isValid():
- return stmt
- stmt = self.findPrevStmt(stmt.startBlock)
- if self.isBlockEnd(stmt):
- nested += 1
-
- if self.isBlockStart(stmt):
- if nested == 0:
- return stmt
- else:
- nested -= 1
-
- def computeSmartIndent(self, block, ch):
- """indent gets three arguments: line, indentWidth in spaces,
- typed character indent
- """
- if not self._isValidTrigger(block, ch):
- return None
-
- prevStmt = self.findPrevStmt(block)
- if not prevStmt.endBlock.isValid():
- return None # Can't indent the first line
-
- prevBlock = self._prevNonEmptyBlock(block)
-
- # HACK Detect here documents
- if self._qpart.isHereDoc(prevBlock.blockNumber(), prevBlock.length() - 2):
- return None
-
- # HACK Detect embedded comments
- if self._qpart.isBlockComment(prevBlock.blockNumber(), prevBlock.length() - 2):
- return None
-
- prevStmtCnt = prevStmt.content()
- prevStmtInd = prevStmt.indent()
-
- # Are we inside a parameter list, array or hash?
- foundBlock, foundColumn, foundChar = self.lastAnchor(block, 0)
- if foundBlock is not None:
- shouldIndent = foundBlock == prevStmt.endBlock or \
- self.testAtEnd(prevStmt, re.compile(',\s*'))
- if (not self._isLastCodeColumn(foundBlock, foundColumn)) or \
- self.lastAnchor(foundBlock, foundColumn)[0] is not None:
- # TODO This is alignment, should force using spaces instead of tabs:
- if shouldIndent:
- foundColumn += 1
- nextCol = self._nextNonSpaceColumn(foundBlock, foundColumn)
- if nextCol > 0 and \
- (not self._isComment(foundBlock, nextCol)):
- foundColumn = nextCol
-
- # Keep indent of previous statement, while aligning to the anchor column
- if len(prevStmtInd) > foundColumn:
- return prevStmtInd
- else:
- return self._makeIndentAsColumn(foundBlock, foundColumn)
- else:
- indent = self._blockIndent(foundBlock)
- if shouldIndent:
- indent = self._increaseIndent(indent)
- return indent
-
- # Handle indenting of multiline statements.
- if (prevStmt.endBlock == block.previous() and \
- self._isBlockContinuing(prevStmt.endBlock)) or \
- self.isStmtContinuing(prevStmt.endBlock):
- if prevStmt.startBlock == prevStmt.endBlock:
- if ch == '' and \
- len(self._blockIndent(block)) > len(self._blockIndent(prevStmt.endBlock)):
- return None # Don't force a specific indent level when aligning manually
- return self._increaseIndent(self._increaseIndent(prevStmtInd))
- else:
- return self._blockIndent(prevStmt.endBlock)
-
- if rxUnindent.match(block.text()):
- startStmt = self.findBlockStart(block)
- if startStmt.startBlock.isValid():
- return startStmt.indent()
- else:
- return None
-
- if self.isBlockStart(prevStmt) and not rxBlockEnd.search(prevStmt.content()):
- return self._increaseIndent(prevStmtInd)
- elif re.search(r'[\[\{]\s*$', prevStmtCnt) is not None:
- return self._increaseIndent(prevStmtInd)
-
- # Keep current
- return prevStmtInd
diff --git a/Orange/widgets/data/utils/pythoneditor/indenter/scheme.py b/Orange/widgets/data/utils/pythoneditor/indenter/scheme.py
deleted file mode 100644
index 32a8d78d17c..00000000000
--- a/Orange/widgets/data/utils/pythoneditor/indenter/scheme.py
+++ /dev/null
@@ -1,79 +0,0 @@
-"""
-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.
-"""
-"""This indenter works according to
- http://community.schemewiki.org/?scheme-style
-
-TODO support (module
-"""
-
-from qutepart.indenter.base import IndentAlgBase
-
-
-class IndentAlgScheme(IndentAlgBase):
- """Indenter for Scheme files
- """
- TRIGGER_CHARACTERS = ""
-
- def _findExpressionEnd(self, block):
- """Find end of the last expression
- """
- while block.isValid():
- column = self._lastColumn(block)
- if column > 0:
- return block, column
- block = block.previous()
- raise UserWarning()
-
- def _lastWord(self, text):
- """Move backward to the start of the word at the end of a string.
- Return the word
- """
- for index, char in enumerate(text[::-1]):
- if char.isspace() or \
- char in ('(', ')'):
- return text[len(text) - index :]
- else:
- return text
-
- def _findExpressionStart(self, block):
- """Find start of not finished expression
- Raise UserWarning, if not found
- """
-
- # raise expession on next level, if not found
- expEndBlock, expEndColumn = self._findExpressionEnd(block)
-
- text = expEndBlock.text()[:expEndColumn + 1]
- if text.endswith(')'):
- try:
- return self.findBracketBackward(expEndBlock, expEndColumn, '(')
- except ValueError:
- raise UserWarning()
- else:
- return expEndBlock, len(text) - len(self._lastWord(text))
-
- def computeSmartIndent(self, block, char):
- """Compute indent for the block
- """
- try:
- foundBlock, foundColumn = self._findExpressionStart(block.previous())
- except UserWarning:
- return ''
- expression = foundBlock.text()[foundColumn:].rstrip()
- beforeExpression = foundBlock.text()[:foundColumn].strip()
-
- if beforeExpression.startswith('(module'): # special case
- return ''
- elif beforeExpression.endswith('define'): # special case
- return ' ' * (len(beforeExpression) - len('define') + 1)
- elif beforeExpression.endswith('let'): # special case
- return ' ' * (len(beforeExpression) - len('let') + 1)
- else:
- return ' ' * foundColumn
diff --git a/Orange/widgets/data/utils/pythoneditor/indenter/xmlindent.py b/Orange/widgets/data/utils/pythoneditor/indenter/xmlindent.py
deleted file mode 100644
index 0093e630465..00000000000
--- a/Orange/widgets/data/utils/pythoneditor/indenter/xmlindent.py
+++ /dev/null
@@ -1,105 +0,0 @@
-"""
-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
-
-from qutepart.indenter.base import IndentAlgBase
-
-class IndentAlgXml(IndentAlgBase):
- """Indenter for XML files
- """
- TRIGGER_CHARACTERS = "/>"
-
- def computeSmartIndent(self, block, char):
- """Compute indent for the block
- """
- lineText = block.text()
- prevLineText = self._prevNonEmptyBlock(block).text()
-
- alignOnly = char == ''
-
- if alignOnly:
- # XML might be all in one line, in which case we want to break that up.
- tokens = re.split(r'>\s*<', lineText)
-
- if len(tokens) > 1:
-
- prevIndent = self._lineIndent(prevLineText)
-
- for index, newLine in enumerate(tokens):
- if index > 0:
- newLine = '<' + newLine
-
- if index < len(tokens) - 1:
- newLine = newLine + '>'
- if re.match(r'^\s*', newLine):
- char = '/'
- elif re.match(r'\\>[^<>]*$', newLine):
- char = '>'
- else:
- char = '\n'
-
- indentation = self.processChar(newLine, prevLineText, char)
- newLine = indentation + newLine
-
- tokens[index] = newLine
- prevLineText = newLine;
-
- self._qpart.lines[block.blockNumber()] = '\n'.join(tokens)
- return None
- else: # no tokens, do not split line, just compute indent
- if re.search(r'^\s*', lineText):
- char = '/'
- elif re.search(r'>[^<>]*', lineText):
- char = '>'
- else:
- char = '\n'
-
- return self.processChar(lineText, prevLineText, char)
-
- def processChar(self, lineText, prevLineText, char):
- prevIndent = self._lineIndent(prevLineText)
- if char == '/':
- if not re.match(r'^\s*', lineText):
- # might happen when we have something like
- # don't change indentation then
- return prevIndent
-
- if not re.match(r'\s*<[^/][^>]*[^/]>[^<>]*$', prevLineText):
- # decrease indent when we write and prior line did not start a tag
- return self._decreaseIndent(prevIndent)
- elif char == '>':
- # increase indent width when we write <...> or <.../> but not
- # and the prior line didn't close a tag
- if not prevLineText: # first line, zero indent
- return ''
- if re.match(r'^<(\?xml|!DOCTYPE).*', prevLineText):
- return ''
- elif re.match(r'^<(\?xml|!DOCTYPE).*', lineText):
- return ''
- elif re.match('^\s*', lineText):
- #closing tag, decrease indentation when previous didn't open a tag
- if re.match(r'\s*<[^/][^>]*[^/]>[^<>]*$', prevLineText):
- # keep indent when prev line opened a tag
- return prevIndent;
- else:
- return self._decreaseIndent(prevIndent)
- elif re.search(r'<([/!][^>]+|[^>]+/)>\s*$', prevLineText):
- # keep indent when prev line closed a tag or was empty or a comment
- return prevIndent
-
- return self._increaseIndent(prevIndent)
- elif char == '\n':
- if re.match(r'^<(\?xml|!DOCTYPE)', prevLineText):
- return ''
- elif re.search(r'<([^/!]|[^/!][^>]*[^/])>[^<>]*$', prevLineText):
- # increase indent when prev line opened a tag (but not for comments)
- return self._increaseIndent(prevIndent)
-
- return prevIndent
diff --git a/Orange/widgets/data/utils/pythoneditor/lines.py b/Orange/widgets/data/utils/pythoneditor/lines.py
index dce9bad0b89..8e7d31cf887 100644
--- a/Orange/widgets/data/utils/pythoneditor/lines.py
+++ b/Orange/widgets/data/utils/pythoneditor/lines.py
@@ -7,12 +7,11 @@
as published by the Free Software Foundation, version 2.1 of the license.
This is compatible with Orange3's GPL-3.0 license.
"""
-"""Lines class.
-list-like object for access text document lines
-"""
-
from PyQt5.QtGui import QTextCursor
+# Lines class.
+# list-like object for access text document lines
+
def _iterateBlocksFrom(block):
while block.isValid():
@@ -20,6 +19,17 @@ def _iterateBlocksFrom(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
"""
@@ -27,15 +37,8 @@ def __init__(self, qpart):
self._qpart = qpart
self._doc = qpart.document()
- def _atomicModification(func):
- """Decorator
- Make document modification atomic
- """
- def wrapper(*args, **kwargs):
- self = args[0]
- with self._qpart:
- func(*args, **kwargs)
- return wrapper
+ def setDocument(self, document):
+ self._doc = document
def _toList(self):
"""Convert to Python list
@@ -89,9 +92,8 @@ def _setBlockText(blockIndex, text):
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
- """
+ # 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
@@ -99,7 +101,8 @@ def _setBlockText(blockIndex, text):
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)))
+ 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)
@@ -127,9 +130,8 @@ def _removeBlock(blockIndex):
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
- """
+ # 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
diff --git a/Orange/widgets/data/utils/pythoneditor/margins.py b/Orange/widgets/data/utils/pythoneditor/margins.py
deleted file mode 100644
index a3b91ccc99f..00000000000
--- a/Orange/widgets/data/utils/pythoneditor/margins.py
+++ /dev/null
@@ -1,201 +0,0 @@
-"""
-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.
-"""
-"""Base class for margins
-"""
-
-
-from PyQt5.QtCore import QPoint, pyqtSignal
-from PyQt5.QtWidgets import QWidget
-from PyQt5.QtGui import QTextBlock
-
-
-class MarginBase:
- """Base class which each margin should derive from
- """
-
- # The parent class derives from QWidget and mixes MarginBase in at
- # run-time. Thus the signal declaration and emmitting works here too.
- blockClicked = pyqtSignal(QTextBlock)
-
- def __init__(self, parent, name, bit_count):
- """qpart: reference to the editor
- name: margin identifier
- bit_count: number of bits to be used by the margin
- """
- self._qpart = parent
- self._name = name
- self._bit_count = bit_count
- self._bitRange = None
- self.__allocateBits()
-
- self._countCache = (-1, -1)
- self._qpart.updateRequest.connect(self.__updateRequest)
-
- 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._qpart.getMargins()
-
- occupiedRanges = []
- for margin in margins:
- bitRange = margin.getBitRange()
- if bitRange is not None:
- # pick the right position
- added = False
- for index in range( len( 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._qpart.blockCount() or \
- self._countCache[1] != self._qpart.textCursor().block().lineCount():
-
- # if block height not added to rect, last line number sometimes is not drawn
- blockHeight = self._qpart.blockBoundingRect(self._qpart.firstVisibleBlock()).height()
-
- self.update(0, rect.y(), self.width(), rect.height() + blockHeight)
- self._countCache = (self._qpart.blockCount(), self._qpart.textCursor().block().lineCount())
-
- if rect.contains(self._qpart.viewport().rect()):
- self._qpart.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():
- QWidget.hide(self)
- self._qpart.updateViewport()
-
- def show(self):
- """Override the QWidget::show() method to properly recalculate the
- editor viewport.
- """
- if self.isHidden():
- QWidget.show(self)
- self._qpart.updateViewport()
-
- def setVisible(self, val):
- """Override the QWidget::setVisible(bool) method to properly
- recalculate the editor viewport.
- """
- if val != self.isVisible():
- if val:
- QWidget.setVisible(self, True)
- else:
- QWidget.setVisible(self, False)
- self._qpart.updateViewport()
-
- def mousePressEvent(self, mouseEvent):
- cursor = self._qpart.cursorForPosition(QPoint(0, mouseEvent.y()))
- block = cursor.block()
- blockRect = self._qpart.blockBoundingGeometry(block).translated(self._qpart.contentOffset())
- if blockRect.bottom() >= mouseEvent.y(): # clicked not lower, then end of text
- self.blockClicked.emit(block)
-
- # Convenience methods
-
- def clear(self):
- """Convenience method to reset all the block values to 0
- """
- if self._bit_count == 0:
- return
-
- block = self._qpart.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/rectangularselection.py b/Orange/widgets/data/utils/pythoneditor/rectangularselection.py
index 7cb48aa4805..8dcc70eff54 100644
--- a/Orange/widgets/data/utils/pythoneditor/rectangularselection.py
+++ b/Orange/widgets/data/utils/pythoneditor/rectangularselection.py
@@ -39,7 +39,7 @@ def _reset(self):
Reset rectangular selection"""
if self._start is not None:
self._start = None
- self._qpart._updateExtraSelections()
+ self._qpart._updateExtraSelections() # pylint: disable=protected-access
def isDeleteKeyEvent(self, keyEvent):
"""Check if key event should be handled as Delete command"""
@@ -54,7 +54,8 @@ def delete(self):
if cursor.hasSelection():
cursor.deleteChar()
- def isExpandKeyEvent(self, keyEvent):
+ @staticmethod
+ def isExpandKeyEvent(keyEvent):
"""Check if key event should expand rectangular selection"""
return keyEvent.modifiers() & Qt.ShiftModifier and \
keyEvent.modifiers() & Qt.AltModifier and \
@@ -66,9 +67,10 @@ def onExpandKeyEvent(self, keyEvent):
if self._start is None:
currentBlockText = self._qpart.textCursor().block().text()
line = self._qpart.cursorPosition[0]
- visibleColumn = self._realToVisibleColumn(currentBlockText, self._qpart.cursorPosition[1])
+ visibleColumn = self._realToVisibleColumn(currentBlockText,
+ self._qpart.cursorPosition[1])
self._start = (line, visibleColumn)
- modifiersWithoutAltShift = keyEvent.modifiers() & ( ~ (Qt.AltModifier | Qt.ShiftModifier))
+ modifiersWithoutAltShift = keyEvent.modifiers() & (~(Qt.AltModifier | Qt.ShiftModifier))
newEvent = QKeyEvent(keyEvent.type(),
keyEvent.key(),
modifiersWithoutAltShift,
@@ -87,7 +89,7 @@ def _visibleCharPositionGenerator(self, text):
currentPos = 0
yield currentPos
- for index, char in enumerate(text):
+ for char in text:
if char == '\t':
currentPos += self._qpart.indentWidth
# trim reminder. If width('\t') == 4, width('abc\t') == 4
@@ -101,7 +103,7 @@ def _realToVisibleColumn(self, text, realColumn):
This function converts real to visible
"""
generator = self._visibleCharPositionGenerator(text)
- for i in range(realColumn):
+ for _ in range(realColumn):
val = next(generator)
val = next(generator)
return val
@@ -153,8 +155,10 @@ def cursors(self):
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),
+ 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)
@@ -199,8 +203,8 @@ def cut(self):
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"""
+ # 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):
@@ -212,7 +216,7 @@ def _indentUpTo(self, text, width):
if diff <= 0:
return ''
elif self._qpart.indentUseTabs and \
- all([char == '\t' for char in text]): # if using tabs and only tabs in text
+ 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:
@@ -231,8 +235,8 @@ def paste(self, mimeData):
lines = text.splitlines()
cursorLine, cursorCol = self._qpart.cursorPosition
if cursorLine + len(lines) > len(self._qpart.lines):
- for i in range(cursorLine + len(lines) - len(self._qpart.lines)):
- self._qpart.lines.append('')
+ for _ in range(cursorLine + len(lines) - len(self._qpart.lines)):
+ self._qpart.lines.append('')
with self._qpart:
for index, line in enumerate(lines):
diff --git a/Orange/widgets/data/utils/pythoneditor/sideareas.py b/Orange/widgets/data/utils/pythoneditor/sideareas.py
deleted file mode 100644
index c2302d00c17..00000000000
--- a/Orange/widgets/data/utils/pythoneditor/sideareas.py
+++ /dev/null
@@ -1,186 +0,0 @@
-"""
-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.
-"""
-"""Line numbers and bookmarks areas
-"""
-
-from PyQt5.QtCore import QPoint, Qt, pyqtSignal, QSize
-from PyQt5.QtWidgets import QWidget, QToolTip
-from PyQt5.QtGui import QPainter, QPalette, QPixmap, QTextBlock
-
-import qutepart
-from qutepart.bookmarks import Bookmarks
-from qutepart.margins import MarginBase
-
-
-
-# Dynamic mixin at runtime:
-# http://stackoverflow.com/questions/8544983/dynamically-mixin-a-base-class-to-an-instance-in-python
-def extend_instance(obj, cls):
- base_cls = obj.__class__
- base_cls_name = obj.__class__.__name__
- obj.__class__ = type(base_cls_name, (base_cls, cls), {})
-
-
-
-class LineNumberArea(QWidget):
- """Line number area widget
- """
- _LEFT_MARGIN = 5
- _RIGHT_MARGIN = 3
-
- def __init__(self, parent):
- QWidget.__init__(self, parent)
-
- extend_instance(self, MarginBase)
- MarginBase.__init__(self, parent, "line_numbers", 0)
-
- self.__width = self.__calculateWidth()
-
- self._qpart.blockCountChanged.connect(self.__updateWidth)
-
- def __updateWidth(self, newBlockCount=None):
- newWidth = self.__calculateWidth()
- if newWidth != self.__width:
- self.__width = newWidth
- self._qpart.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._qpart.firstVisibleBlock()
- blockNumber = block.blockNumber()
- top = int(self._qpart.blockBoundingGeometry(block).translated(self._qpart.contentOffset()).top())
- bottom = top + int(self._qpart.blockBoundingRect(block).height())
- singleBlockHeight = self._qpart.cursorRect().height()
-
- boundingRect = self._qpart.blockBoundingRect(block)
- availableWidth = self.__width - self._RIGHT_MARGIN - self._LEFT_MARGIN
- availableHeight = self._qpart.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._qpart.blockBoundingRect(block)
- top = bottom
- bottom = top + int(boundingRect.height())
- blockNumber += 1
-
- def __calculateWidth(self):
- digits = len(str(max(1, self._qpart.blockCount())))
- return self._LEFT_MARGIN + self._qpart.fontMetrics().width('9') * digits + self._RIGHT_MARGIN
-
- def width(self):
- """Desired width. Includes text and margins
- """
- return self.__width
-
- def setFont(self, font):
- QWidget.setFont(self, font)
- self.__updateWidth()
-
-
-class MarkArea(QWidget):
-
- _MARGIN = 1
-
- def __init__(self, qpart):
- QWidget.__init__(self, qpart)
-
- extend_instance(self, MarginBase)
- MarginBase.__init__(self, qpart, "mark_area", 1)
-
- qpart.blockCountChanged.connect(self.update)
-
- self.setMouseTracking(True)
-
- self._bookmarkPixmap = self._loadIcon('emblem-favorite')
- self._lintPixmaps = {qpart.LINT_ERROR: self._loadIcon('emblem-error'),
- qpart.LINT_WARNING: self._loadIcon('emblem-warning'),
- qpart.LINT_NOTE: self._loadIcon('emblem-information')}
-
- self._bookmarks = Bookmarks(qpart, self)
-
- def _loadIcon(self, fileName):
- icon = qutepart.getIcon(fileName)
- size = self._qpart.cursorRect().height() - 6
- pixmap = icon.pixmap(size, size) # This also works with Qt.AA_UseHighDpiPixmaps
- return pixmap
-
- def sizeHint(self, ):
- """QWidget.sizeHint() implementation
- """
- return QSize(self.width(), 0)
-
- def paintEvent(self, event):
- """QWidget.paintEvent() implementation
- Draw markers
- """
- painter = QPainter(self)
- painter.fillRect(event.rect(), self.palette().color(QPalette.Window))
-
- block = self._qpart.firstVisibleBlock()
- blockBoundingGeometry = self._qpart.blockBoundingGeometry(block).translated(self._qpart.contentOffset())
- top = blockBoundingGeometry.top()
- bottom = top + blockBoundingGeometry.height()
-
- for block in qutepart.iterateBlocksFrom(block):
- height = self._qpart.blockBoundingGeometry(block).height()
- if top > event.rect().bottom():
- break
- if block.isVisible() and \
- bottom >= event.rect().top():
- if block.blockNumber() in self._qpart.lintMarks:
- msgType, msgText = self._qpart.lintMarks[block.blockNumber()]
- pixMap = self._lintPixmaps[msgType]
- yPos = top + ((height - pixMap.height()) / 2) # centered
- painter.drawPixmap(0, yPos, pixMap)
-
- if self.isBlockMarked(block):
- yPos = top + ((height - self._bookmarkPixmap.height()) / 2) # centered
- painter.drawPixmap(0, yPos, self._bookmarkPixmap)
-
- top += height
-
- def width(self):
- """Desired width. Includes text and margins
- """
- return self._MARGIN + self._bookmarkPixmap.width() + self._MARGIN
-
- def mouseMoveEvent(self, event):
- blockNumber = self._qpart.cursorForPosition(event.pos()).blockNumber()
- if blockNumber in self._qpart._lintMarks:
- msgType, msgText = self._qpart._lintMarks[blockNumber]
- QToolTip.showText(event.globalPos(), msgText)
- else:
- QToolTip.hideText()
-
- return QWidget.mouseMoveEvent(self, event)
-
- def clearBookmarks(self, startBlock, endBlock):
- """Clears the bookmarks
- """
- self._bookmarks.clear(startBlock, endBlock)
-
- def clear(self):
- self._bookmarks.removeActions()
- MarginBase.clear(self)
-
diff --git a/Orange/widgets/data/utils/pythoneditor/syntax/__init__.py b/Orange/widgets/data/utils/pythoneditor/syntax/__init__.py
deleted file mode 100644
index be384b1d4d4..00000000000
--- a/Orange/widgets/data/utils/pythoneditor/syntax/__init__.py
+++ /dev/null
@@ -1,269 +0,0 @@
-"""
-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.
-"""
-"""Source file parser and highlighter
-"""
-
-import os.path
-import fnmatch
-import json
-import threading
-import logging
-import re
-
-_logger = logging.getLogger('qutepart')
-
-class TextFormat:
- """Text format definition.
-
- Public attributes:
- color : Font color, #rrggbb or #rgb
- background : Font background, #rrggbb or #rgb
- selectionColor : Color of selected text
- italic : Italic font, bool
- bold : Bold font, bool
- underline : Underlined font, bool
- strikeOut : Striked out font
- spellChecking : Text will be spell checked
- textType : 'c' for comments, 's' for strings, ' ' for other.
- """
- def __init__(self, color = '#000000',
- background = '#ffffff',
- selectionColor = '#0000ff',
- italic = False,
- bold = False,
- underline = False,
- strikeOut = False,
- spellChecking = False):
-
- self.color = color
- self.background = background
- self.selectionColor = selectionColor
- self.italic = italic
- self.bold = bold
- self.underline = underline
- self.strikeOut = strikeOut
- self.spellChecking = spellChecking
- self.textType = ' ' # modified later
-
- def __cmp__(self, other):
- return cmp(self.__dict__, other.__dict__)
-
-
-class Syntax:
- """Syntax. Programming language parser definition
-
- Public attributes:
- name Name
- section Section
- extensions File extensions
- mimetype File mime type
- version XML definition version
- kateversion Required Kate parser version
- priority XML definition priority
- author Author
- license License
- hidden Shall be hidden in the menu
- indenter Indenter for the syntax. Possible values are
- none, normal, cstyle, haskell, lilypond, lisp, python, ruby, xml
- None, if not set by xml file
- """
- def __init__(self, manager):
- self.manager = manager
- self.parser = None
-
- def __str__(self):
- res = 'Syntax\n'
- res += ' name: %s\n' % self.name
- res += ' section: %s\n' % self.section
- res += ' extensions: %s\n' % self.extensions
- res += ' mimetype: %s\n' % self.mimetype
- res += ' version: %s\n' % self.version
- res += ' kateversion: %s\n' % self.kateversion
- res += ' priority: %s\n' % self.priority
- res += ' author: %s\n' % self.author
- res += ' license: %s\n' % self.license
- res += ' hidden: %s\n' % self.hidden
- res += ' indenter: %s\n' % self.indenter
- res += str(self.parser)
-
- return res
-
- def _setParser(self, parser):
- self.parser = parser
- # performance optimization, avoid 1 function call
- self.highlightBlock = parser.highlightBlock
- self.parseBlock = parser.parseBlock
-
- def highlightBlock(self, text, prevLineData):
- """Parse line of text and return
- (lineData, highlightedSegments)
- where
- lineData is data, which shall be saved and used for parsing next line
- highlightedSegments is list of touples (segmentLength, segmentFormat)
- """
- #self.parser.parseAndPrintBlockTextualResults(text, prevLineData)
- return self.parser.highlightBlock(text, prevLineData)
-
- def parseBlock(self, text, prevLineData):
- """Parse line of text and return
- lineData
- where
- lineData is data, which shall be saved and used for parsing next line
-
- This is quicker version of highlighBlock, which doesn't return results,
- but only parsers the block and produces data, which is necessary for parsing next line.
- Use it for invisible lines
- """
- return self.parser.parseBlock(text, prevLineData)
-
- def _getTextType(self, lineData, column):
- """Get text type (letter)
- """
- if lineData is None:
- return ' ' # default is code
-
- textTypeMap = lineData[1]
- if column >= len(textTypeMap): # probably, not actual data, not updated yet
- return ' '
-
- return textTypeMap[column]
-
- def isCode(self, lineData, column):
- """Check if text at given position is a code
- """
- return self._getTextType(lineData, column) == ' '
-
- def isComment(self, lineData, column):
- """Check if text at given position is a comment. Including block comments and here documents
- """
- return self._getTextType(lineData, column) in 'cbh'
-
- def isBlockComment(self, lineData, column):
- """Check if text at given position is a block comment
- """
- return self._getTextType(lineData, column) == 'b'
-
- def isHereDoc(self, lineData, column):
- """Check if text at given position is a here document
- """
- return self._getTextType(lineData, column) == 'h'
-
-
-class SyntaxManager:
- """SyntaxManager holds references to loaded Syntax'es and allows to find or
- load Syntax by its name or by source file name
- """
- def __init__(self):
- self._loadedSyntaxesLock = threading.RLock()
- self._loadedSyntaxes = {}
- syntaxDbPath = os.path.join(os.path.abspath(os.path.dirname(__file__)), "data", "syntax_db.json")
- with open(syntaxDbPath, encoding='utf-8') as syntaxDbFile:
- syntaxDb = json.load(syntaxDbFile)
- self._syntaxNameToXmlFileName = syntaxDb['syntaxNameToXmlFileName']
- self._mimeTypeToXmlFileName = syntaxDb['mimeTypeToXmlFileName']
- self._firstLineToXmlFileName = syntaxDb['firstLineToXmlFileName']
- globToXmlFileName = syntaxDb['extensionToXmlFileName']
-
- # Applying glob patterns is really slow. Therefore they are compiled to reg exps
- self._extensionToXmlFileName = \
- {re.compile(fnmatch.translate(glob)): xmlFileName \
- for glob, xmlFileName in globToXmlFileName.items()}
-
- def _getSyntaxByXmlFileName(self, xmlFileName):
- """Get syntax by its xml file name
- """
- import qutepart.syntax.loader # delayed import for avoid cross-imports problem
-
- with self._loadedSyntaxesLock:
- if not xmlFileName in self._loadedSyntaxes:
- xmlFilePath = os.path.join(os.path.dirname(__file__), "data", "xml", xmlFileName)
- syntax = Syntax(self)
- self._loadedSyntaxes[xmlFileName] = syntax
- qutepart.syntax.loader.loadSyntax(syntax, xmlFilePath)
-
- return self._loadedSyntaxes[xmlFileName]
-
- def _getSyntaxByLanguageName(self, syntaxName):
- """Get syntax by its name. Name is defined in the xml file
- """
- xmlFileName = self._syntaxNameToXmlFileName[syntaxName]
- return self._getSyntaxByXmlFileName(xmlFileName)
-
- def _getSyntaxBySourceFileName(self, name):
- """Get syntax by source name of file, which is going to be highlighted
- """
- for regExp, xmlFileName in self._extensionToXmlFileName.items():
- if regExp.match(name):
- return self._getSyntaxByXmlFileName(xmlFileName)
- else:
- raise KeyError("No syntax for " + name)
-
- def _getSyntaxByMimeType(self, mimeType):
- """Get syntax by first line of the file
- """
- xmlFileName = self._mimeTypeToXmlFileName[mimeType]
- return self._getSyntaxByXmlFileName(xmlFileName)
-
- def _getSyntaxByFirstLine(self, firstLine):
- """Get syntax by first line of the file
- """
- for pattern, xmlFileName in self._firstLineToXmlFileName.items():
- if fnmatch.fnmatch(firstLine, pattern):
- return self._getSyntaxByXmlFileName(xmlFileName)
- else:
- raise KeyError("No syntax for " + firstLine)
-
- def getSyntax(self,
- xmlFileName=None,
- mimeType=None,
- languageName=None,
- sourceFilePath=None,
- firstLine=None):
- """Get syntax by one of parameters:
- * xmlFileName
- * mimeType
- * languageName
- * sourceFilePath
- First parameter in the list has biggest priority
- """
- syntax = None
-
- if syntax is None and xmlFileName is not None:
- try:
- syntax = self._getSyntaxByXmlFileName(xmlFileName)
- except KeyError:
- _logger.warning('No xml definition %s' % xmlFileName)
-
- if syntax is None and mimeType is not None:
- try:
- syntax = self._getSyntaxByMimeType(mimeType)
- except KeyError:
- _logger.warning('No syntax for mime type %s' % mimeType)
-
- if syntax is None and languageName is not None:
- try:
- syntax = self._getSyntaxByLanguageName(languageName)
- except KeyError:
- _logger.warning('No syntax for language %s' % languageName)
-
- if syntax is None and sourceFilePath is not None:
- baseName = os.path.basename(sourceFilePath)
- try:
- syntax = self._getSyntaxBySourceFileName(baseName)
- except KeyError:
- pass
-
- if syntax is None and firstLine is not None:
- try:
- syntax = self._getSyntaxByFirstLine(firstLine)
- except KeyError:
- pass
-
- return syntax
diff --git a/Orange/widgets/data/utils/pythoneditor/syntax/colortheme.py b/Orange/widgets/data/utils/pythoneditor/syntax/colortheme.py
deleted file mode 100644
index 0680c7d8a5f..00000000000
--- a/Orange/widgets/data/utils/pythoneditor/syntax/colortheme.py
+++ /dev/null
@@ -1,61 +0,0 @@
-"""
-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.
-"""
-"""Default color theme
-"""
-
-class ColorTheme:
- """Color theme.
- """
- def __init__(self, textFormatClass):
- """Constructor gets TextFormat class as parameter for avoid cross-import problems
- """
- self.format = {
- 'dsNormal': textFormatClass(),
- 'dsKeyword': textFormatClass(bold=True),
- 'dsFunction': textFormatClass(color='#644a9a'),
- 'dsVariable': textFormatClass(color='#0057ad'),
- 'dsControlFlow': textFormatClass(bold=True),
- 'dsOperator': textFormatClass(),
- 'dsBuiltIn': textFormatClass(color='#644a9a', bold=True),
- 'dsExtension': textFormatClass(color='#0094fe', bold=True),
- 'dsPreprocessor': textFormatClass(color='#006e28'),
- 'dsAttribute': textFormatClass(color='#0057ad'),
-
- 'dsChar': textFormatClass(color='#914c9c'),
- 'dsSpecialChar': textFormatClass(color='#3dade8'),
- 'dsString': textFormatClass(color='#be0303'),
- 'dsVerbatimString': textFormatClass(color='#be0303'),
- 'dsSpecialString': textFormatClass(color='#fe5500'),
- 'dsImport': textFormatClass(color='#b969c3'),
-
- 'dsDataType': textFormatClass(color='#0057ad'),
- 'dsDecVal': textFormatClass(color='#af8000'),
- 'dsBaseN': textFormatClass(color='#af8000'),
- 'dsFloat': textFormatClass(color='#af8000'),
-
- 'dsConstant': textFormatClass(bold=True),
-
- 'dsComment': textFormatClass(color='#888786'),
- 'dsDocumentation': textFormatClass(color='#608880'),
- 'dsAnnotation': textFormatClass(color='#0094fe'),
- 'dsCommentVar': textFormatClass(color='#c960c9'),
-
- 'dsRegionMarker': textFormatClass(color='#0057ad', background='#e0e9f8'),
- 'dsInformation': textFormatClass(color='#af8000'),
- 'dsWarning': textFormatClass(color='#be0303'),
- 'dsAlert': textFormatClass(color='#bf0303', background='#f7e6e6', bold=True),
- 'dsOthers': textFormatClass(color='#006e28'),
- 'dsError': textFormatClass(color='#bf0303', underline=True),
- }
-
- def getFormat(self, styleName):
- """Returns TextFormat for particular style
- """
- return self.format[styleName]
diff --git a/Orange/widgets/data/utils/pythoneditor/syntax/data/regenerate-definitions-db.py b/Orange/widgets/data/utils/pythoneditor/syntax/data/regenerate-definitions-db.py
deleted file mode 100755
index a949d268403..00000000000
--- a/Orange/widgets/data/utils/pythoneditor/syntax/data/regenerate-definitions-db.py
+++ /dev/null
@@ -1,97 +0,0 @@
-"""
-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.
-"""
-#!/usr/bin/env python3
-
-import os.path
-import json
-
-import sys
-
-_MY_PATH = os.path.abspath(os.path.dirname(__file__))
-sys.path.insert(0, os.path.join(_MY_PATH, '..', '..', '..'))
-
-
-from qutepart.syntax.loader import loadSyntax
-from qutepart.syntax import SyntaxManager, Syntax
-
-
-def _add_php(targetFileName, srcFileName):
- os.system("./generate-php.pl > xml/{} < xml/{}".format(targetFileName, srcFileName))
-
-
-def main():
- os.chdir(_MY_PATH)
- _add_php('javascript-php.xml', 'javascript.xml')
- _add_php('css-php.xml', 'css.xml')
- _add_php('html-php.xml', 'html.xml')
-
- xmlFilesPath = os.path.join(_MY_PATH, 'xml')
- xmlFileNames = [fileName for fileName in os.listdir(xmlFilesPath) \
- if fileName.endswith('.xml')]
-
- syntaxNameToXmlFileName = {}
- mimeTypeToXmlFileName = {}
- extensionToXmlFileName = {}
- firstLineToXmlFileName = {}
-
- for xmlFileName in xmlFileNames:
- xmlFilePath = os.path.join(xmlFilesPath, xmlFileName)
- syntax = Syntax(None)
- loadSyntax(syntax, xmlFilePath)
- if not syntax.name in syntaxNameToXmlFileName or \
- syntaxNameToXmlFileName[syntax.name][0] < syntax.priority:
- syntaxNameToXmlFileName[syntax.name] = (syntax.priority, xmlFileName)
-
- if syntax.mimetype:
- for mimetype in syntax.mimetype:
- if not mimetype in mimeTypeToXmlFileName or \
- mimeTypeToXmlFileName[mimetype][0] < syntax.priority:
- mimeTypeToXmlFileName[mimetype] = (syntax.priority, xmlFileName)
-
- if syntax.extensions:
- for extension in syntax.extensions:
- if extension not in extensionToXmlFileName or \
- extensionToXmlFileName[extension][0] < syntax.priority:
- extensionToXmlFileName[extension] = (syntax.priority, xmlFileName)
-
- if syntax.firstLineGlobs:
- for glob in syntax.firstLineGlobs:
- if not glob in firstLineToXmlFileName or \
- firstLineToXmlFileName[glob][0] < syntax.priority:
- firstLineToXmlFileName[glob] = (syntax.priority, xmlFileName)
-
- # remove priority, leave only xml file names
- for dictionary in (syntaxNameToXmlFileName,
- mimeTypeToXmlFileName,
- extensionToXmlFileName,
- firstLineToXmlFileName):
- newDictionary = {}
- for key, item in dictionary.items():
- newDictionary[key] = item[1]
- dictionary.clear()
- dictionary.update(newDictionary)
-
- # Fix up php first line pattern. It contains %&*/;?[]^{|}~\\"
-
-def _parseBoolAttribute(value):
- if value.lower() in ('true', '1'):
- return True
- elif value.lower() in ('false', '0'):
- return False
- else:
- raise UserWarning("Invalid bool attribute value '%s'" % value)
-
-def _safeGetRequiredAttribute(xmlElement, name, default):
- if name in xmlElement.attrib:
- return str(xmlElement.attrib[name])
- else:
- _logger.warning("Required attribute '%s' is not set for element '%s'", name, xmlElement.tag)
- return default
-
-
-def _getContext(contextName, parser, defaultValue):
- if not contextName:
- return defaultValue
- if contextName in parser.contexts:
- return parser.contexts[contextName]
- elif contextName.startswith('##') and \
- parser.syntax.manager is not None: # might be None, if loader is used by regenerate-definitions-db.py
- syntaxName = contextName[2:]
- parser = parser.syntax.manager.getSyntax(languageName = syntaxName).parser
- return parser.defaultContext
- elif (not contextName.startswith('##')) and \
- '##' in contextName and \
- contextName.count('##') == 1 and \
- parser.syntax.manager is not None: # might be None, if loader is used by regenerate-definitions-db.py
- name, syntaxName = contextName.split('##')
- parser = parser.syntax.manager.getSyntax(languageName = syntaxName).parser
- return parser.contexts[name]
- else:
- _logger.warning('Invalid context name %s', repr(contextName))
- return parser.defaultContext
-
-
-def _makeContextSwitcher(contextOperation, parser):
- popsCount = 0
- contextToSwitch = None
-
- rest = contextOperation
- while rest.startswith('#pop'):
- popsCount += 1
- rest = rest[len('#pop'):]
- if rest.startswith('!'):
- rest = rest[1:]
-
- if rest == '#stay':
- if popsCount:
- _logger.warning("Invalid context operation '%s'", contextOperation)
- else:
- contextToSwitch = _getContext(rest, parser, None)
-
- if popsCount > 0 or contextToSwitch != None:
- return _parserModule.ContextSwitcher(popsCount, contextToSwitch, contextOperation)
- else:
- return None
-
-
-################################################################################
-## Rules
-################################################################################
-
-def _loadIncludeRules(parentContext, xmlElement, attributeToFormatMap):
- contextName = _safeGetRequiredAttribute(xmlElement, "context", None)
-
- context = _getContext(contextName, parentContext.parser, parentContext.parser.defaultContext)
-
- abstractRuleParams = _loadAbstractRuleParams(parentContext,
- xmlElement,
- attributeToFormatMap)
- return _parserModule.IncludeRules(abstractRuleParams, context)
-
-def _simpleLoader(classObject):
- def _load(parentContext, xmlElement, attributeToFormatMap):
- abstractRuleParams = _loadAbstractRuleParams(parentContext,
- xmlElement,
- attributeToFormatMap)
- return classObject(abstractRuleParams)
- return _load
-
-def _loadChildRules(context, xmlElement, attributeToFormatMap):
- """Extract rules from Context or Rule xml element
- """
- rules = []
- for ruleElement in xmlElement.getchildren():
- if not ruleElement.tag in _ruleClassDict:
- raise ValueError("Not supported rule '%s'" % ruleElement.tag)
- rule = _ruleClassDict[ruleElement.tag](context, ruleElement, attributeToFormatMap)
- rules.append(rule)
- return rules
-
-def _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap):
- # attribute
- attribute = xmlElement.attrib.get("attribute", None)
- if attribute is not None:
- attribute = attribute.lower() # not case sensitive
- try:
- format = attributeToFormatMap[attribute]
- textType = format.textType if format is not None else ' '
- if format is not None:
- format = _convertFormat(format)
- except KeyError:
- _logger.warning('Unknown rule attribute %s', attribute)
- format = parentContext.format
- textType = parentContext.textType
- else:
- format = None
- textType = None
-
- # context
- contextText = xmlElement.attrib.get("context", '#stay')
- context = _makeContextSwitcher(contextText, parentContext.parser)
-
- lookAhead = _parseBoolAttribute(xmlElement.attrib.get("lookAhead", "false"))
- firstNonSpace = _parseBoolAttribute(xmlElement.attrib.get("firstNonSpace", "false"))
- dynamic = _parseBoolAttribute(xmlElement.attrib.get("dynamic", "false"))
-
- # TODO beginRegion
- # TODO endRegion
-
- column = xmlElement.attrib.get("column", None)
- if column is not None:
- column = int(column)
- else:
- column = -1
-
- return _parserModule.AbstractRuleParams(parentContext, format, textType, attribute, context, lookAhead, firstNonSpace, dynamic, column)
-
-def _loadDetectChar(parentContext, xmlElement, attributeToFormatMap):
- abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
-
- char = _safeGetRequiredAttribute(xmlElement, "char", None)
- if char is not None:
- char = _processEscapeSequences(char)
-
- index = 0
- if abstractRuleParams.dynamic:
- try:
- index = int(char)
- except ValueError:
- _logger.warning('Invalid DetectChar char %s', char)
- index = 0
- char = None
- if index <= 0:
- _logger.warning('Too little DetectChar index %d', index)
- index = 0
-
- return _parserModule.DetectChar(abstractRuleParams, str(char), index)
-
-def _loadDetect2Chars(parentContext, xmlElement, attributeToFormatMap):
- char = _safeGetRequiredAttribute(xmlElement, 'char', None)
- char1 = _safeGetRequiredAttribute(xmlElement, 'char1', None)
- if char is None or char1 is None:
- string = None
- else:
- string = _processEscapeSequences(char) + _processEscapeSequences(char1)
-
- abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
- return _parserModule.Detect2Chars(abstractRuleParams, string)
-
-def _loadAnyChar(parentContext, xmlElement, attributeToFormatMap):
- string = _safeGetRequiredAttribute(xmlElement, 'String', '')
- abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
- return _parserModule.AnyChar(abstractRuleParams, string)
-
-def _loadStringDetect(parentContext, xmlElement, attributeToFormatMap):
- string = _safeGetRequiredAttribute(xmlElement, 'String', None)
-
- abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
- return _parserModule.StringDetect(abstractRuleParams,
- string)
-
-def _loadWordDetect(parentContext, xmlElement, attributeToFormatMap):
- word = _safeGetRequiredAttribute(xmlElement, "String", "")
- insensitive = _parseBoolAttribute(xmlElement.attrib.get("insensitive", "false"))
-
- abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
-
- return _parserModule.WordDetect(abstractRuleParams, word, insensitive)
-
-def _loadKeyword(parentContext, xmlElement, attributeToFormatMap):
- string = _safeGetRequiredAttribute(xmlElement, 'String', None)
- try:
- words = parentContext.parser.lists[string]
- except KeyError:
- _logger.warning("List '%s' not found", string)
-
- words = list()
-
- insensitive = _parseBoolAttribute(xmlElement.attrib.get("insensitive", "false"))
-
- abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
- return _parserModule.keyword(abstractRuleParams, words, insensitive)
-
-def _loadRegExpr(parentContext, xmlElement, attributeToFormatMap):
- def _processCraracterCodes(text):
- """QRegExp use \0ddd notation for character codes, where d in octal digit
- i.e. \0377 is character with code 255 in the unicode table
- Convert such notation to unicode text
- """
- text = str(text)
- def replFunc(matchObj):
- matchText = matchObj.group(0)
- charCode = eval('0o' + matchText[2:])
- return chr(charCode)
- return re.sub(r"\\0\d\d\d", replFunc, text)
-
- insensitive = _parseBoolAttribute(xmlElement.attrib.get('insensitive', 'false'))
- minimal = _parseBoolAttribute(xmlElement.attrib.get('minimal', 'false'))
- string = _safeGetRequiredAttribute(xmlElement, 'String', None)
-
- if string is not None:
- string = _processCraracterCodes(string)
-
- strippedString = string.strip('(')
-
- wordStart = strippedString.startswith('\\b')
- lineStart = strippedString.startswith('^')
- if len(strippedString) > 1 and strippedString[1] == '|': # ^|blabla This condition is not ideal but will cover majority of cases
- wordStart = False
- lineStart = False
- else:
- wordStart = False
- lineStart = False
-
- abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
- return _parserModule.RegExpr(abstractRuleParams,
- string, insensitive, minimal, wordStart, lineStart)
-
-def _loadAbstractNumberRule(rule, parentContext, xmlElement):
- abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
- return _parserModule.NumberRule(abstractRuleParams, childRules)
-
-def _loadInt(parentContext, xmlElement, attributeToFormatMap):
- childRules = _loadChildRules(parentContext, xmlElement, attributeToFormatMap)
- abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
- return _parserModule.Int(abstractRuleParams, childRules)
-
-def _loadFloat(parentContext, xmlElement, attributeToFormatMap):
- childRules = _loadChildRules(parentContext, xmlElement, attributeToFormatMap)
- abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
- return _parserModule.Float(abstractRuleParams, childRules)
-
-def _loadRangeDetect(parentContext, xmlElement, attributeToFormatMap):
- char = _safeGetRequiredAttribute(xmlElement, "char", 'char is not set')
- char1 = _safeGetRequiredAttribute(xmlElement, "char1", 'char1 is not set')
-
- abstractRuleParams = _loadAbstractRuleParams(parentContext, xmlElement, attributeToFormatMap)
- return _parserModule.RangeDetect(abstractRuleParams, char, char1)
-
-
-_ruleClassDict = \
-{
- 'DetectChar': _loadDetectChar,
- 'Detect2Chars': _loadDetect2Chars,
- 'AnyChar': _loadAnyChar,
- 'StringDetect': _loadStringDetect,
- 'WordDetect': _loadWordDetect,
- 'RegExpr': _loadRegExpr,
- 'keyword': _loadKeyword,
- 'Int': _loadInt,
- 'Float': _loadFloat,
- 'HlCOct': _simpleLoader(_parserModule.HlCOct),
- 'HlCHex': _simpleLoader(_parserModule.HlCHex),
- 'HlCStringChar': _simpleLoader(_parserModule.HlCStringChar),
- 'HlCChar': _simpleLoader(_parserModule.HlCChar),
- 'RangeDetect': _loadRangeDetect,
- 'LineContinue': _simpleLoader(_parserModule.LineContinue),
- 'IncludeRules': _loadIncludeRules,
- 'DetectSpaces': _simpleLoader(_parserModule.DetectSpaces),
- 'DetectIdentifier': _simpleLoader(_parserModule.DetectIdentifier)
-}
-
-################################################################################
-## Context
-################################################################################
-
-
-def _loadContexts(highlightingElement, parser, attributeToFormatMap):
- contextsElement = highlightingElement.find('contexts')
-
- xmlElementList = contextsElement.findall('context')
- contextList = []
- for xmlElement in xmlElementList:
- name = _safeGetRequiredAttribute(xmlElement,
- 'name',
- 'Error: context name is not set!!!')
- context = _parserModule.Context(parser, name)
- contextList.append(context)
-
- defaultContext = contextList[0]
-
- contextDict = {}
- for context in contextList:
- contextDict[context.name] = context
-
- parser.setContexts(contextDict, defaultContext)
-
- # parse contexts stage 2: load contexts
- for xmlElement, context in zip(xmlElementList, contextList):
- _loadContext(context, xmlElement, attributeToFormatMap)
-
-
-def _loadContext(context, xmlElement, attributeToFormatMap):
- """Construct context from XML element
- Contexts are at first constructed, and only then loaded, because when loading context,
- _makeContextSwitcher must have references to all defined contexts
- """
- attribute = _safeGetRequiredAttribute(xmlElement, 'attribute', '').lower()
- if attribute != '': # there are no attributes for internal contexts, used by rules. See perl.xml
- try:
- format = attributeToFormatMap[attribute]
- except KeyError:
- _logger.warning('Unknown context attribute %s', attribute)
- format = TextFormat()
- else:
- format = None
-
- textType = format.textType if format is not None else ' '
- if format is not None:
- format = _convertFormat(format)
-
- lineEndContextText = xmlElement.attrib.get('lineEndContext', '#stay')
- lineEndContext = _makeContextSwitcher(lineEndContextText, context.parser)
- lineBeginContextText = xmlElement.attrib.get('lineBeginContext', '#stay')
- lineBeginContext = _makeContextSwitcher(lineBeginContextText, context.parser)
- lineEmptyContextText = xmlElement.attrib.get('lineEmptyContext', '#stay')
- lineEmptyContext = _makeContextSwitcher(lineEmptyContextText, context.parser)
-
- if _parseBoolAttribute(xmlElement.attrib.get('fallthrough', 'false')):
- fallthroughContextText = _safeGetRequiredAttribute(xmlElement, 'fallthroughContext', '#stay')
- fallthroughContext = _makeContextSwitcher(fallthroughContextText, context.parser)
- else:
- fallthroughContext = None
-
- dynamic = _parseBoolAttribute(xmlElement.attrib.get('dynamic', 'false'))
-
- context.setValues(attribute, format, lineEndContext, lineBeginContext, lineEmptyContext, fallthroughContext, dynamic, textType)
-
- # load rules
- rules = _loadChildRules(context, xmlElement, attributeToFormatMap)
- context.setRules(rules)
-
-################################################################################
-## Syntax
-################################################################################
-def _textTypeForDefStyleName(attribute, defStyleName):
- """ ' ' for code
- 'c' for comments
- 'b' for block comments
- 'h' for here documents
- """
- if 'here' in attribute.lower() and defStyleName == 'dsOthers':
- return 'h' # ruby
- elif 'block' in attribute.lower() and defStyleName == 'dsComment':
- return 'b'
- elif defStyleName in ('dsString', 'dsRegionMarker', 'dsChar', 'dsOthers'):
- return 's'
- elif defStyleName == 'dsComment':
- return 'c'
- else:
- return ' '
-
-def _makeFormat(defaultTheme, defaultStyleName, textType, item=None):
- format = copy.copy(defaultTheme.format[defaultStyleName])
-
- format.textType = textType
-
- if item is not None:
- caseInsensitiveAttributes = {}
- for key, value in item.attrib.items():
- caseInsensitiveAttributes[key.lower()] = value.lower()
-
- if 'color' in caseInsensitiveAttributes:
- format.color = caseInsensitiveAttributes['color']
- if 'selColor' in caseInsensitiveAttributes:
- format.selectionColor = caseInsensitiveAttributes['selColor']
- if 'italic' in caseInsensitiveAttributes:
- format.italic = _parseBoolAttribute(caseInsensitiveAttributes['italic'])
- if 'bold' in caseInsensitiveAttributes:
- format.bold = _parseBoolAttribute(caseInsensitiveAttributes['bold'])
- if 'underline' in caseInsensitiveAttributes:
- format.underline = _parseBoolAttribute(caseInsensitiveAttributes['underline'])
- if 'strikeout' in caseInsensitiveAttributes:
- format.strikeOut = _parseBoolAttribute(caseInsensitiveAttributes['strikeout'])
- if 'spellChecking' in caseInsensitiveAttributes:
- format.spellChecking = _parseBoolAttribute(caseInsensitiveAttributes['spellChecking'])
-
- return format
-
-def _loadAttributeToFormatMap(highlightingElement):
- defaultTheme = ColorTheme(TextFormat)
- attributeToFormatMap = {}
-
- itemDatasElement = highlightingElement.find('itemDatas')
- if itemDatasElement is not None:
- for item in itemDatasElement.findall('itemData'):
- attribute = item.get('name').lower()
- defaultStyleName = item.get('defStyleNum')
-
- if not defaultStyleName in defaultTheme.format:
- _logger.warning("Unknown default style '%s'", defaultStyleName)
- defaultStyleName = 'dsNormal'
-
- format = _makeFormat(defaultTheme,
- defaultStyleName,
- _textTypeForDefStyleName(attribute, defaultStyleName),
- item)
-
- attributeToFormatMap[attribute] = format
-
- # HACK not documented, but 'normal' attribute is used by some parsers without declaration
- if not 'normal' in attributeToFormatMap:
- attributeToFormatMap['normal'] = _makeFormat(defaultTheme, 'dsNormal',
- _textTypeForDefStyleName('normal', 'dsNormal'))
- if not 'string' in attributeToFormatMap:
- attributeToFormatMap['string'] = _makeFormat(defaultTheme, 'dsString',
- _textTypeForDefStyleName('string', 'dsString'))
-
- return attributeToFormatMap
-
-def _loadLists(root, highlightingElement):
- lists = {} # list name: list
- for listElement in highlightingElement.findall('list'):
- # Sometimes item.text is none. Broken xml files
- items = [str(item.text.strip()) \
- for item in listElement.findall('item') \
- if item.text is not None]
- name = _safeGetRequiredAttribute(listElement, 'name', 'Error: list name is not set!!!')
- lists[name] = items
-
- return lists
-
-def _makeKeywordsLowerCase(listDict):
- # Make all keywords lowercase, if syntax is not case sensitive
- for keywordList in listDict.values():
- for index, keyword in enumerate(keywordList):
- keywordList[index] = keyword.lower()
-
-def _loadSyntaxDescription(root, syntax):
- syntax.name = _safeGetRequiredAttribute(root, 'name', 'Error: .parser name is not set!!!')
- syntax.section = _safeGetRequiredAttribute(root, 'section', 'Error: Section is not set!!!')
- syntax.extensions = [_f for _f in _safeGetRequiredAttribute(root, 'extensions', '').split(';') if _f]
- syntax.firstLineGlobs = [_f for _f in root.attrib.get('firstLineGlobs', '').split(';') if _f]
- syntax.mimetype = [_f for _f in root.attrib.get('mimetype', '').split(';') if _f]
- syntax.version = root.attrib.get('version', None)
- syntax.kateversion = root.attrib.get('kateversion', None)
- syntax.priority = int(root.attrib.get('priority', '0'))
- syntax.author = root.attrib.get('author', None)
- syntax.license = root.attrib.get('license', None)
- syntax.hidden = _parseBoolAttribute(root.attrib.get('hidden', 'false'))
-
- # not documented
- syntax.indenter = root.attrib.get('indenter', None)
-
-
-def loadSyntax(syntax, filePath = None):
- _logger.debug("Loading syntax %s", filePath)
- with open(filePath, 'r', encoding='utf-8') as definitionFile:
- try:
- root = xml.etree.ElementTree.parse(definitionFile).getroot()
- except Exception as ex:
- print('When opening %s:' % filePath, file=sys.stderr)
- raise
-
- highlightingElement = root.find('highlighting')
-
- _loadSyntaxDescription(root, syntax)
-
- deliminatorSet = set(_DEFAULT_DELIMINATOR)
-
- # parse lists
- lists = _loadLists(root, highlightingElement)
-
- # parse itemData
- keywordsCaseSensitive = True
-
- generalElement = root.find('general')
- if generalElement is not None:
- keywordsElement = generalElement.find('keywords')
-
- if keywordsElement is not None:
- keywordsCaseSensitive = _parseBoolAttribute(keywordsElement.get('casesensitive', "true"))
-
- if not keywordsCaseSensitive:
- _makeKeywordsLowerCase(lists)
-
- if 'weakDeliminator' in keywordsElement.attrib:
- weakSet = keywordsElement.attrib['weakDeliminator']
- deliminatorSet.difference_update(weakSet)
-
- if 'additionalDeliminator' in keywordsElement.attrib:
- additionalSet = keywordsElement.attrib['additionalDeliminator']
- deliminatorSet.update(additionalSet)
-
- indentationElement = generalElement.find('indentation')
-
- if indentationElement is not None and \
- 'mode' in indentationElement.attrib:
- syntax.indenter = indentationElement.attrib['mode']
-
- deliminatorSetAsString = ''.join(list(deliminatorSet))
- debugOutputEnabled = _logger.isEnabledFor(logging.DEBUG) # for cParser
- parser = _parserModule.Parser(syntax, deliminatorSetAsString, lists, keywordsCaseSensitive, debugOutputEnabled)
- syntax._setParser(parser)
- attributeToFormatMap = _loadAttributeToFormatMap(highlightingElement)
-
- # parse contexts
- _loadContexts(highlightingElement, syntax.parser, attributeToFormatMap)
-
- return syntax
diff --git a/Orange/widgets/data/utils/pythoneditor/syntax/parser.py b/Orange/widgets/data/utils/pythoneditor/syntax/parser.py
deleted file mode 100644
index 3a7becb4803..00000000000
--- a/Orange/widgets/data/utils/pythoneditor/syntax/parser.py
+++ /dev/null
@@ -1,1003 +0,0 @@
-"""
-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.
-"""
-"""Kate syntax definition parser and representation
-
-Do not use this module directly. Use 'syntax' module
-
-Read http://kate-editor.org/2005/03/24/writing-a-syntax-highlighting-file/
-if you want to understand something
-
-
-'attribute' property of rules and contexts contains not an original string,
-but value from itemDatas section (style name)
-
-'context', 'lineBeginContext', 'lineEndContext', 'fallthroughContext' properties
-contain not a text value, but ContextSwitcher object
-"""
-
-import re
-import logging
-
-_logger = logging.getLogger('qutepart')
-
-_numSeqReplacer = re.compile('%\d+')
-
-
-class ContextStack:
- def __init__(self, contexts, data):
- """Create default context stack for syntax
- Contains default context on the top
- """
- self._contexts = contexts
- self._data = data
-
- def pop(self, count):
- """Returns new context stack, which doesn't contain few levels
- """
- if len(self._contexts) - 1 < count:
- _logger.error("#pop value is too big %d", len(self._contexts))
- if len(self._contexts) > 1:
- return ContextStack(self._contexts[:1], self._data[:1])
- else:
- return self
-
- return ContextStack(self._contexts[:-count], self._data[:-count])
-
- def append(self, context, data):
- """Returns new context, which contains current stack and new frame
- """
- return ContextStack(self._contexts + [context], self._data + [data])
-
- def currentContext(self):
- """Get current context
- """
- return self._contexts[-1]
-
- def currentData(self):
- """Get current data
- """
- return self._data[-1]
-
-
-class ContextSwitcher:
- """Class parses 'context', 'lineBeginContext', 'lineEndContext', 'fallthroughContext'
- and modifies context stack according to context operation
- """
- def __init__(self, popsCount, contextToSwitch, contextOperation):
- self._popsCount = popsCount
- self._contextToSwitch = contextToSwitch
- self._contextOperation = contextOperation
-
- def __str__(self):
- return self._contextOperation
-
- def getNextContextStack(self, contextStack, data=None):
- """Apply modification to the contextStack.
- This method never modifies input parameter list
- """
- if self._popsCount:
- contextStack = contextStack.pop(self._popsCount)
-
- if self._contextToSwitch is not None:
- if not self._contextToSwitch.dynamic:
- data = None
- contextStack = contextStack.append(self._contextToSwitch, data)
-
- return contextStack
-
-
-class TextToMatchObject:
- """Peace of text, which shall be matched.
- Contains pre-calculated and pre-checked data for performance optimization
- """
- def __init__(self, currentColumnIndex, wholeLineText, deliminatorSet, contextData):
- self.currentColumnIndex = currentColumnIndex
- self.wholeLineText = wholeLineText
- self.text = wholeLineText[currentColumnIndex:]
- self.textLen = len(self.text)
-
- self.firstNonSpace = not bool(wholeLineText[:currentColumnIndex].strip())
-
- self.isWordStart = currentColumnIndex == 0 or \
- wholeLineText[currentColumnIndex - 1].isspace() or \
- wholeLineText[currentColumnIndex - 1] in deliminatorSet
-
- self.word = None
- if self.isWordStart:
- wordEndIndex = 0
- for index, char in enumerate(self.text):
- if char in deliminatorSet:
- wordEndIndex = index
- break
- else:
- wordEndIndex = len(wholeLineText)
-
- if wordEndIndex != 0:
- self.word = self.text[:wordEndIndex]
-
- self.contextData = contextData
-
-
-class RuleTryMatchResult:
- def __init__(self, rule, length, data=None):
- self.rule = rule
- self.length = length
- self.data = data
-
- if rule.lookAhead:
- self.length = 0
-
-
-class AbstractRuleParams:
- """Parameters, passed to the AbstractRule constructor
- """
- def __init__(self, parentContext, format, textType, attribute, context, lookAhead, firstNonSpace, dynamic, column):
- self.parentContext = parentContext
- self.format = format
- self.textType = textType
- self.attribute = attribute
- self.context = context
- self.lookAhead = lookAhead
- self.firstNonSpace = firstNonSpace
- self.dynamic = dynamic
- self.column = column
-
-
-class AbstractRule:
- """Base class for rule classes
- Public attributes:
- parentContext
- format May be None
- textType May be None
- attribute May be None
- context
- lookAhead
- firstNonSpace
- column -1 if not set
- dynamic
- """
-
- _seqReplacer = re.compile('%\d+')
-
- def __init__(self, params):
- self.parentContext = params.parentContext
- self.format = params.format
- self.textType = params.textType
- self.attribute = params.attribute
- self.context = params.context
- self.lookAhead = params.lookAhead
- self.firstNonSpace = params.firstNonSpace
- self.dynamic = params.dynamic
- self.column = params.column
-
- def __str__(self):
- """Serialize.
- For debug logs
- """
- res = '\t\tRule %s\n' % self.shortId()
- res += '\t\t\tstyleName: %s\n' % (self.attribute or 'None')
- res += '\t\t\tcontext: %s\n' % self.context
- return res
-
- def shortId(self):
- """Get short ID string of the rule. Used for logs
- i.e. "DetectChar(x)"
- """
- raise NotImplementedError(str(self.__class__))
-
- def tryMatch(self, textToMatchObject):
- """Try to find themselves in the text.
- Returns (contextStack, count, matchedRule) or (contextStack, None, None) if doesn't match
- """
- # Skip if column doesn't match
- if self.column != -1 and \
- self.column != textToMatchObject.currentColumnIndex:
- return None
-
- if self.firstNonSpace and \
- (not textToMatchObject.firstNonSpace):
- return None
-
- return self._tryMatch(textToMatchObject)
-
-
-class DetectChar(AbstractRule):
- """Public attributes:
- char
- """
- def __init__(self, abstractRuleParams, char, index):
- AbstractRule.__init__(self, abstractRuleParams)
- self.char = char
- self.index = index
-
- def shortId(self):
- return 'DetectChar(%s, %d)' % (self.char, self.index)
-
- def _tryMatch(self, textToMatchObject):
- if self.char is None and self.index == 0:
- return None
-
- if self.dynamic:
- index = self.index - 1
- if index >= len(textToMatchObject.contextData):
- _logger.error('Invalid DetectChar index %d', index)
- return None
-
- if len(textToMatchObject.contextData[index]) != 1:
- _logger.error('Too long DetectChar string %s', textToMatchObject.contextData[index])
- return None
-
- string = textToMatchObject.contextData[index]
- else:
- string = self.char
-
- if textToMatchObject.text[0] == string:
- return RuleTryMatchResult(self, 1)
- return None
-
-
-class Detect2Chars(AbstractRule):
- """Public attributes
- string
- """
- def __init__(self, abstractRuleParams, string):
- AbstractRule.__init__(self, abstractRuleParams)
- self.string = string
-
- def shortId(self):
- return 'Detect2Chars(%s)' % self.string
-
- def _tryMatch(self, textToMatchObject):
- if self.string is None:
- return None
-
- if textToMatchObject.text.startswith(self.string):
- return RuleTryMatchResult(self, len(self.string))
-
- return None
-
-
-class AnyChar(AbstractRule):
- """Public attributes:
- string
- """
- def __init__(self, abstractRuleParams, string):
- AbstractRule.__init__(self, abstractRuleParams)
- self.string = string
-
- def shortId(self):
- return 'AnyChar(%s)' % self.string
-
- def _tryMatch(self, textToMatchObject):
- if textToMatchObject.text[0] in self.string:
- return RuleTryMatchResult(self, 1)
-
- return None
-
-
-class StringDetect(AbstractRule):
- """Public attributes:
- string
- """
- def __init__(self, abstractRuleParams, string):
- AbstractRule.__init__(self, abstractRuleParams)
- self.string = string
-
- def shortId(self):
- return 'StringDetect(%s)' % self.string
-
- def _tryMatch(self, textToMatchObject):
- if self.string is None:
- return None
-
- if self.dynamic:
- string = self._makeDynamicSubsctitutions(self.string, textToMatchObject.contextData)
- if not string:
- return None
- else:
- string = self.string
-
- if textToMatchObject.text.startswith(string):
- return RuleTryMatchResult(self, len(string))
-
- return None
-
- @staticmethod
- def _makeDynamicSubsctitutions(string, contextData):
- """For dynamic rules, replace %d patterns with actual strings
- Python function, which is used by C extension.
- """
- def _replaceFunc(escapeMatchObject):
- stringIndex = escapeMatchObject.group(0)[1]
- index = int(stringIndex)
- if index < len(contextData):
- return contextData[index]
- else:
- return escapeMatchObject.group(0) # no any replacements, return original value
-
- return _numSeqReplacer.sub(_replaceFunc, string)
-
-
-class WordDetect(AbstractRule):
- """Public attributes:
- words
- """
- def __init__(self, abstractRuleParams, word, insensitive):
- AbstractRule.__init__(self, abstractRuleParams)
- self.word = word
- self.insensitive = insensitive
-
- def shortId(self):
- return 'WordDetect(%s, %d)' % (self.word, self.insensitive)
-
- def _tryMatch(self, textToMatchObject):
- if textToMatchObject.word is None:
- return None
-
- if self.insensitive or \
- (not self.parentContext.parser.keywordsCaseSensitive):
- wordToCheck = textToMatchObject.word.lower()
- else:
- wordToCheck = textToMatchObject.word
-
- if wordToCheck == self.word:
- return RuleTryMatchResult(self, len(wordToCheck))
- else:
- return None
-
-
-class keyword(AbstractRule):
- """Public attributes:
- string
- words
- """
- def __init__(self, abstractRuleParams, words, insensitive):
- AbstractRule.__init__(self, abstractRuleParams)
- self.words = set(words)
- self.insensitive = insensitive
-
- def shortId(self):
- return 'keyword(%s, %d)' % (' '.join(list(self.words)), self.insensitive)
-
- def _tryMatch(self, textToMatchObject):
- if textToMatchObject.word is None:
- return None
-
- if self.insensitive or \
- (not self.parentContext.parser.keywordsCaseSensitive):
- wordToCheck = textToMatchObject.word.lower()
- else:
- wordToCheck = textToMatchObject.word
-
- if wordToCheck in self.words:
- return RuleTryMatchResult(self, len(wordToCheck))
- else:
- return None
-
-
-class RegExpr(AbstractRule):
- """ Public attributes:
- regExp
- wordStart
- lineStart
- """
- def __init__(self, abstractRuleParams,
- string, insensitive, minimal, wordStart, lineStart):
- AbstractRule.__init__(self, abstractRuleParams)
- self.string = string
- self.insensitive = insensitive
- self.minimal = minimal
- self.wordStart = wordStart
- self.lineStart = lineStart
-
- if self.dynamic:
- self.regExp = None
- else:
- self.regExp = self._compileRegExp(string, insensitive, minimal)
-
-
- def shortId(self):
- return 'RegExpr( %s )' % self.string
-
- def _tryMatch(self, textToMatchObject):
- """Tries to parse text. If matched - saves data for dynamic context
- """
- # Special case. if pattern starts with \b, we have to check it manually,
- # because string is passed to .match(..) without beginning
- if self.wordStart and \
- (not textToMatchObject.isWordStart):
- return None
-
- #Special case. If pattern starts with ^ - check column number manually
- if self.lineStart and \
- textToMatchObject.currentColumnIndex > 0:
- return None
-
- if self.dynamic:
- string = self._makeDynamicSubsctitutions(self.string, textToMatchObject.contextData)
- regExp = self._compileRegExp(string, self.insensitive, self.minimal)
- else:
- regExp = self.regExp
-
- if regExp is None:
- return None
-
- wholeMatch, groups = self._matchPattern(regExp, textToMatchObject.text)
- if wholeMatch is not None:
- count = len(wholeMatch)
- return RuleTryMatchResult(self, count, groups)
- else:
- return None
-
- @staticmethod
- def _makeDynamicSubsctitutions(string, contextData):
- """For dynamic rules, replace %d patterns with actual strings
- Escapes reg exp symbols in the pattern
- Python function, used by C code
- """
- def _replaceFunc(escapeMatchObject):
- stringIndex = escapeMatchObject.group(0)[1]
- index = int(stringIndex)
- if index < len(contextData):
- return re.escape(contextData[index])
- else:
- return escapeMatchObject.group(0) # no any replacements, return original value
-
- return _numSeqReplacer.sub(_replaceFunc, string)
-
- @staticmethod
- def _compileRegExp(string, insensitive, minimal):
- """Compile regular expression.
- Python function, used by C code
-
- NOTE minimal flag is not supported here, but supported on PCRE
- """
- flags = 0
- if insensitive:
- flags = re.IGNORECASE
-
- string = string.replace('[_[:alnum:]]', '[\\w\\d]') # ad-hoc fix for C++ parser
- string = string.replace('[:digit:]', '\\d')
- string = string.replace('[:blank:]', '\\s')
-
- try:
- return re.compile(string, flags)
- except (re.error, AssertionError) as ex:
- _logger.warning("Invalid pattern '%s': %s", string, str(ex))
- return None
-
- @staticmethod
- def _matchPattern(regExp, string):
- """Try to match pattern.
- Returns tuple (whole match, groups) or (None, None)
- Python function, used by C code
- """
- match = regExp.match(string)
- if match is not None and match.group(0):
- return match.group(0), (match.group(0), ) + match.groups()
- else:
- return None, None
-
-
-class AbstractNumberRule(AbstractRule):
- """Base class for Int and Float rules.
- This rules can have child rules
-
- Public attributes:
- childRules
- """
- def __init__(self, abstractRuleParams, childRules):
- AbstractRule.__init__(self, abstractRuleParams)
- self.childRules = childRules
-
- def _tryMatch(self, textToMatchObject):
- """Try to find themselves in the text.
- Returns (count, matchedRule) or (None, None) if doesn't match
- """
-
- # andreikop: This check is not described in kate docs, and I haven't found it in the code
- if not textToMatchObject.isWordStart:
- return None
-
- index = self._tryMatchText(textToMatchObject.text)
- if index is None:
- return None
-
- if textToMatchObject.currentColumnIndex + index < len(textToMatchObject.wholeLineText):
- newTextToMatchObject = TextToMatchObject(textToMatchObject.currentColumnIndex + index,
- textToMatchObject.wholeLineText,
- self.parentContext.parser.deliminatorSet,
- textToMatchObject.contextData)
- for rule in self.childRules:
- ruleTryMatchResult = rule.tryMatch(newTextToMatchObject)
- if ruleTryMatchResult is not None:
- index += ruleTryMatchResult.length
- break
- # child rule context and attribute ignored
-
- return RuleTryMatchResult(self, index)
-
- def _countDigits(self, text):
- """Count digits at start of text
- """
- index = 0
- while index < len(text):
- if not text[index].isdigit():
- break
- index += 1
- return index
-
-
-class Int(AbstractNumberRule):
- def shortId(self):
- return 'Int()'
-
- def _tryMatchText(self, text):
- matchedLength = self._countDigits(text)
-
- if matchedLength:
- return matchedLength
- else:
- return None
-
-
-class Float(AbstractNumberRule):
- def shortId(self):
- return 'Float()'
-
- def _tryMatchText(self, text):
-
- haveDigit = False
- havePoint = False
-
- matchedLength = 0
-
- digitCount = self._countDigits(text[matchedLength:])
- if digitCount:
- haveDigit = True
- matchedLength += digitCount
-
- if len(text) > matchedLength and text[matchedLength] == '.':
- havePoint = True
- matchedLength += 1
-
- digitCount = self._countDigits(text[matchedLength:])
- if digitCount:
- haveDigit = True
- matchedLength += digitCount
-
- if len(text) > matchedLength and text[matchedLength].lower() == 'e':
- matchedLength += 1
-
- if len(text) > matchedLength and text[matchedLength] in '+-':
- matchedLength += 1
-
- haveDigitInExponent = False
-
- digitCount = self._countDigits(text[matchedLength:])
- if digitCount:
- haveDigitInExponent = True
- matchedLength += digitCount
-
- if not haveDigitInExponent:
- return None
-
- return matchedLength
- else:
- if not havePoint:
- return None
-
- if matchedLength and haveDigit:
- return matchedLength
- else:
- return None
-
-
-class HlCOct(AbstractRule):
- def shortId(self):
- return 'HlCOct'
-
- def _tryMatch(self, textToMatchObject):
- if textToMatchObject.text[0] != '0':
- return None
-
- index = 1
- while index < len(textToMatchObject.text) and textToMatchObject.text[index] in '01234567':
- index += 1
-
- if index == 1:
- return None
-
- if index < len(textToMatchObject.text) and textToMatchObject.text[index].upper() in 'LU':
- index += 1
-
- return RuleTryMatchResult(self, index)
-
-
-class HlCHex(AbstractRule):
- def shortId(self):
- return 'HlCHex'
-
- def _tryMatch(self, textToMatchObject):
- if len(textToMatchObject.text) < 3:
- return None
-
- if textToMatchObject.text[:2].upper() != '0X':
- return None
-
- index = 2
- while index < len(textToMatchObject.text) and textToMatchObject.text[index].upper() in '0123456789ABCDEF':
- index += 1
-
- if index == 2:
- return None
-
- if index < len(textToMatchObject.text) and textToMatchObject.text[index].upper() in 'LU':
- index += 1
-
- return RuleTryMatchResult(self, index)
-
-
-def _checkEscapedChar(text):
- index = 0
- if len(text) > 1 and text[0] == '\\':
- index = 1
-
- if text[index] in "abefnrtv'\"?\\":
- index += 1
- elif text[index] == 'x': # if it's like \xff, eat the x
- index += 1
- while index < len(text) and text[index].upper() in '0123456789ABCDEF':
- index += 1
- if index == 2: # no hex digits
- return None
- elif text[index] in '01234567':
- while index < 4 and index < len(text) and text[index] in '01234567':
- index += 1
- else:
- return None
-
- return index
-
- return None
-
-
-class HlCStringChar(AbstractRule):
- def shortId(self):
- return 'HlCStringChar'
-
- def _tryMatch(self, textToMatchObject):
- res = _checkEscapedChar(textToMatchObject.text)
- if res is not None:
- return RuleTryMatchResult(self, res)
- else:
- return None
-
-
-class HlCChar(AbstractRule):
- def shortId(self):
- return 'HlCChar'
-
- def _tryMatch(self, textToMatchObject):
- if len(textToMatchObject.text) > 2 and textToMatchObject.text[0] == "'" and textToMatchObject.text[1] != "'":
- result = _checkEscapedChar(textToMatchObject.text[1:])
- if result is not None:
- index = 1 + result
- else: # 1 not escaped character
- index = 1 + 1
-
- if index < len(textToMatchObject.text) and textToMatchObject.text[index] == "'":
- return RuleTryMatchResult(self, index + 1)
-
- return None
-
-
-class RangeDetect(AbstractRule):
- """Public attributes:
- char
- char1
- """
- def __init__(self, abstractRuleParams, char, char1):
- AbstractRule.__init__(self, abstractRuleParams)
- self.char = char
- self.char1 = char1
-
- def shortId(self):
- return 'RangeDetect(%s, %s)' % (self.char, self.char1)
-
- def _tryMatch(self, textToMatchObject):
- if textToMatchObject.text.startswith(self.char):
- end = textToMatchObject.text.find(self.char1, 1)
- if end > 0:
- return RuleTryMatchResult(self, end + 1)
-
- return None
-
-
-class LineContinue(AbstractRule):
- def shortId(self):
- return 'LineContinue'
-
- def _tryMatch(self, textToMatchObject):
- if textToMatchObject.text == '\\':
- return RuleTryMatchResult(self, 1)
-
- return None
-
-
-class IncludeRules(AbstractRule):
- def __init__(self, abstractRuleParams, context):
- AbstractRule.__init__(self, abstractRuleParams)
- self.context = context
-
- def __str__(self):
- """Serialize.
- For debug logs
- """
- res = '\t\tRule %s\n' % self.shortId()
- res += '\t\t\tstyleName: %s\n' % (self.attribute or 'None')
- return res
-
- def shortId(self):
- return "IncludeRules(%s)" % self.context.name
-
- def _tryMatch(self, textToMatchObject):
- """Try to find themselves in the text.
- Returns (count, matchedRule) or (None, None) if doesn't match
- """
- for rule in self.context.rules:
- ruleTryMatchResult = rule.tryMatch(textToMatchObject)
- if ruleTryMatchResult is not None:
- _logger.debug('\tmatched rule %s at %d in included context %s/%s',
- rule.shortId(),
- textToMatchObject.currentColumnIndex,
- self.context.parser.syntax.name,
- self.context.name)
- return ruleTryMatchResult
- else:
- return None
-
-
-class DetectSpaces(AbstractRule):
- def shortId(self):
- return 'DetectSpaces()'
-
- def _tryMatch(self, textToMatchObject):
- spaceLen = len(textToMatchObject.text) - len(textToMatchObject.text.lstrip())
- if spaceLen:
- return RuleTryMatchResult(self, spaceLen)
- else:
- return None
-
-
-class DetectIdentifier(AbstractRule):
- _regExp = re.compile('[a-zA-Z][a-zA-Z0-9_]*')
- def shortId(self):
- return 'DetectIdentifier()'
-
- def _tryMatch(self, textToMatchObject):
- match = DetectIdentifier._regExp.match(textToMatchObject.text)
- if match is not None and match.group(0):
- return RuleTryMatchResult(self, len(match.group(0)))
-
- return None
-
-
-class Context:
- """Highlighting context
-
- Public attributes:
- attribute
- lineEndContext
- lineBeginContext
- fallthroughContext
- dynamic
- rules
- textType ' ' : code, 'c' : comment
- """
- def __init__(self, parser, name):
- # Will be initialized later, after all context has been created
- self.parser = parser
- self.name = name
-
- def setValues(self, attribute, format, lineEndContext, lineBeginContext, lineEmptyContext, fallthroughContext, dynamic, textType):
- self.attribute = attribute
- self.format = format
- self.lineEndContext = lineEndContext
- self.lineBeginContext = lineBeginContext
- self.lineEmptyContext = lineEmptyContext
- self.fallthroughContext = fallthroughContext
- self.dynamic = dynamic
- self.textType = textType
-
- def setRules(self, rules):
- self.rules = rules
-
- def __str__(self):
- """Serialize.
- For debug logs
- """
- res = '\tContext %s\n' % self.name
- res += '\t\t%s: %s\n' % ('attribute', self.attribute)
- res += '\t\t%s: %s\n' % ('lineEndContext', self.lineEndContext)
- res += '\t\t%s: %s\n' % ('lineBeginContext', self.lineBeginContext)
- res += '\t\t%s: %s\n' % ('lineEmptyContext', self.lineEmptyContext)
- if self.fallthroughContext is not None:
- res += '\t\t%s: %s\n' % ('fallthroughContext', self.fallthroughContext)
- res += '\t\t%s: %s\n' % ('dynamic', self.dynamic)
-
- for rule in self.rules:
- res += str(rule)
- return res
-
- def parseBlock(self, contextStack, currentColumnIndex, text):
- """Parse block
- Exits, when reached end of the text, or when context is switched
- Returns (length, newContextStack, highlightedSegments, lineContinue)
- """
- startColumnIndex = currentColumnIndex
- countOfNotMatchedSymbols = 0
- highlightedSegments = []
- textTypeMap = []
- ruleTryMatchResult = None
- while currentColumnIndex < len(text):
- textToMatchObject = TextToMatchObject(currentColumnIndex,
- text,
- self.parser.deliminatorSet,
- contextStack.currentData())
- for rule in self.rules:
- ruleTryMatchResult = rule.tryMatch(textToMatchObject)
- if ruleTryMatchResult is not None: # if something matched
- _logger.debug('\tmatched rule %s at %d',
- rule.shortId(),
- currentColumnIndex)
- if countOfNotMatchedSymbols > 0:
- highlightedSegments.append((countOfNotMatchedSymbols, self.format))
- textTypeMap += [self.textType for i in range(countOfNotMatchedSymbols)]
- countOfNotMatchedSymbols = 0
-
- if ruleTryMatchResult.rule.context is not None:
- newContextStack = ruleTryMatchResult.rule.context.getNextContextStack(contextStack,
- ruleTryMatchResult.data)
- else:
- newContextStack = contextStack
-
- format = ruleTryMatchResult.rule.format if ruleTryMatchResult.rule.attribute else newContextStack.currentContext().format
- textType = ruleTryMatchResult.rule.textType or newContextStack.currentContext().textType
-
- highlightedSegments.append((ruleTryMatchResult.length,
- format))
- textTypeMap += textType * ruleTryMatchResult.length
-
- currentColumnIndex += ruleTryMatchResult.length
-
- if newContextStack != contextStack:
- lineContinue = isinstance(ruleTryMatchResult.rule, LineContinue)
-
- return currentColumnIndex - startColumnIndex, newContextStack, highlightedSegments, textTypeMap, lineContinue
-
- break # for loop
- else: # no matched rules
- if self.fallthroughContext is not None:
- newContextStack = self.fallthroughContext.getNextContextStack(contextStack)
- if newContextStack != contextStack:
- if countOfNotMatchedSymbols > 0:
- highlightedSegments.append((countOfNotMatchedSymbols, self.format))
- textTypeMap += [self.textType for i in range(countOfNotMatchedSymbols)]
- return (currentColumnIndex - startColumnIndex, newContextStack, highlightedSegments, textTypeMap, False)
-
- currentColumnIndex += 1
- countOfNotMatchedSymbols += 1
-
- if countOfNotMatchedSymbols > 0:
- highlightedSegments.append((countOfNotMatchedSymbols, self.format))
- textTypeMap += [self.textType for i in range(countOfNotMatchedSymbols)]
-
- lineContinue = ruleTryMatchResult is not None and \
- isinstance(ruleTryMatchResult.rule, LineContinue)
-
- return currentColumnIndex - startColumnIndex, contextStack, highlightedSegments, textTypeMap, lineContinue
-
-
-class Parser:
- """Parser implementation
-
- syntax Syntax instance
-
- attributeToFormatMap Map "attribute" : TextFormat
-
- deliminatorSet Set of deliminator characters
- lists Keyword lists as dictionary "list name" : "list value"
- keywordsCaseSensitive If true, keywords are not case sensitive
-
- contexts Context list as dictionary "context name" : context
- defaultContext Default context object
- """
- def __init__(self, syntax, deliminatorSetAsString, lists, keywordsCaseSensitive, debugOutputEnabled):
- self.syntax = syntax
- self.deliminatorSet = set(deliminatorSetAsString)
- self.lists = lists
- self.keywordsCaseSensitive = keywordsCaseSensitive
- # debugOutputEnabled is used only by cParser
-
- def setContexts(self, contexts, defaultContext):
- self.contexts = contexts
- self.defaultContext = defaultContext
- self._defaultContextStack = ContextStack([self.defaultContext], [None])
-
- def __str__(self):
- """Serialize.
- For debug logs
- """
- res = 'Parser\n'
- for name, value in vars(self).items():
- if not name.startswith('_') and \
- not name in ('defaultContext', 'deliminatorSet', 'contexts', 'lists', 'syntax') and \
- not value is None:
- res += '\t%s: %s\n' % (name, value)
-
- res += '\tDefault context: %s\n' % self.defaultContext.name
-
- for listName, listValue in self.lists.items():
- res += '\tList %s: %s\n' % (listName, listValue)
-
-
- for context in self.contexts.values():
- res += str(context)
-
- return res
-
- def highlightBlock(self, text, prevContextStack):
- """Parse block and return ParseBlockFullResult
-
- return (lineData, highlightedSegments)
- where lineData is (contextStack, textTypeMap)
- where textTypeMap is a string of textType characters
- """
- if prevContextStack is not None:
- contextStack = prevContextStack
- else:
- contextStack = self._defaultContextStack
-
- highlightedSegments = []
- lineContinue = False
- currentColumnIndex = 0
- textTypeMap = []
-
- if len(text) > 0:
- while currentColumnIndex < len(text):
- _logger.debug('In context %s', contextStack.currentContext().name)
-
- length, newContextStack, segments, textTypeMapPart, lineContinue = \
- contextStack.currentContext().parseBlock(contextStack, currentColumnIndex, text)
-
- highlightedSegments += segments
- contextStack = newContextStack
- textTypeMap += textTypeMapPart
- currentColumnIndex += length
-
- if not lineContinue:
- while contextStack.currentContext().lineEndContext is not None:
- oldStack = contextStack
- contextStack = contextStack.currentContext().lineEndContext.getNextContextStack(contextStack)
- if oldStack == contextStack: # avoid infinite while loop if nothing to switch
- break
-
- # this code is not tested, because lineBeginContext is not defined by any xml file
- if contextStack.currentContext().lineBeginContext is not None:
- contextStack = contextStack.currentContext().lineBeginContext.getNextContextStack(contextStack)
- elif contextStack.currentContext().lineEmptyContext is not None:
- contextStack = contextStack.currentContext().lineEmptyContext.getNextContextStack(contextStack)
-
- lineData = (contextStack, textTypeMap)
- return lineData, highlightedSegments
-
- def parseBlock(self, text, prevContextStack):
- return self.highlightBlock(text, prevContextStack)[0]
diff --git a/Orange/widgets/data/utils/pythoneditor/syntaxhlighter.py b/Orange/widgets/data/utils/pythoneditor/syntaxhlighter.py
deleted file mode 100644
index 9502a592bb6..00000000000
--- a/Orange/widgets/data/utils/pythoneditor/syntaxhlighter.py
+++ /dev/null
@@ -1,304 +0,0 @@
-"""
-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.
-"""
-"""QSyntaxHighlighter implementation
-Uses syntax module for doing the job
-"""
-
-import time
-
-from PyQt5.QtCore import QObject, QTimer, pyqtSlot
-from PyQt5.QtWidgets import QApplication
-from PyQt5.QtGui import QTextBlockUserData, QTextLayout
-
-import qutepart.syntax
-
-
-def _cmpFormatRanges(a, b):
- """PyQt does not define proper comparison for QTextLayout.FormatRange
- Define it to check correctly, if formats has changed.
- It is important for the performance
- """
- if a.format == b.format and \
- a.start == b.start and \
- a.length == b.length:
- return 0
- else:
- return cmp(id(a), id(b))
-
-
-def _formatRangeListsEqual(a, b):
- if len(a) != len(b):
- return False
-
- for a_item, b_item in zip(a, b):
- if a_item != b_item:
- return False
-
- return True
-
-
-class _TextBlockUserData(QTextBlockUserData):
- def __init__(self, data):
- QTextBlockUserData.__init__(self)
- self.data = data
-
-
-class GlobalTimer:
- """All parsing and highlighting is done in main loop thread.
- If parsing is being done for long time, main loop gets blocked.
- Therefore SyntaxHighlighter controls, how long parsign is going, and, if too long,
- schedules timer and releases main loop.
- One global timer is used by all Qutepart instances, because main loop time usage
- must not depend on opened files count
- """
-
- def __init__(self):
- self._timer = QTimer(QApplication.instance())
- self._timer.setSingleShot(True)
- self._timer.timeout.connect(self._onTimer)
-
- self._scheduledCallbacks = []
-
- def isActive(self):
- return self._timer.isActive()
-
- def scheduleCallback(self, callback):
- if not callback in self._scheduledCallbacks:
- self._scheduledCallbacks.append(callback)
- self._timer.start()
-
- def unScheduleCallback(self, callback):
- if callback in self._scheduledCallbacks:
- self._scheduledCallbacks.remove(callback)
-
- if not self._scheduledCallbacks:
- self._timer.stop()
-
- def isCallbackScheduled(self, callback):
- return callback in self._scheduledCallbacks
-
- def _onTimer(self):
- if self._scheduledCallbacks:
- callback = self._scheduledCallbacks.pop()
- callback()
- if self._scheduledCallbacks:
- self._timer.start()
-
-
-"""Global var, because main loop time usage shall not depend on Qutepart instances count
-
-Pyside crashes, if this variable is a class field
-"""
-_gLastChangeTime = -777.
-
-
-class SyntaxHighlighter(QObject):
-
- # when initially parsing text, it is better, if highlighted text is drawn without flickering
- _MAX_PARSING_TIME_BIG_CHANGE_SEC = 0.4
- # when user is typing text - response shall be quick
- _MAX_PARSING_TIME_SMALL_CHANGE_SEC = 0.02
-
- _globalTimer = GlobalTimer()
-
- def __init__(self, syntax, textEdit):
- QObject.__init__(self, textEdit.document())
-
- self._syntax = syntax
- self._textEdit = textEdit
- self._document = textEdit.document()
-
- # can't store references to block, Qt crashes if block removed
- self._pendingBlockNumber = None
- self._pendingAtLeastUntilBlockNumber = None
-
- self._document.contentsChange.connect(self._onContentsChange)
-
- charsAdded = self._document.lastBlock().position() + self._document.lastBlock().length()
- self._onContentsChange(0, 0, charsAdded, zeroTimeout=self._wasChangedJustBefore())
-
- def terminate(self):
- try:
- self._document.contentsChange.disconnect(self._onContentsChange)
- except TypeError:
- pass
-
- self._globalTimer.unScheduleCallback(self._onContinueHighlighting)
- block = self._document.firstBlock()
- while block.isValid():
- block.layout().setAdditionalFormats([])
- block.setUserData(None)
- self._document.markContentsDirty(block.position(), block.length())
- block = block.next()
- self._globalTimer.unScheduleCallback(self._onContinueHighlighting)
-
- def syntax(self):
- """Return own syntax
- """
- return self._syntax
-
- def isInProgress(self):
- """Highlighting is in progress
- """
- return self._globalTimer.isCallbackScheduled(self._onContinueHighlighting)
-
- def isCode(self, block, column):
- """Check if character at column is a a code
- """
- dataObject = block.userData()
- data = dataObject.data if dataObject is not None else None
- return self._syntax.isCode(data, column)
-
- def isComment(self, block, column):
- """Check if character at column is a comment
- """
- dataObject = block.userData()
- data = dataObject.data if dataObject is not None else None
- return self._syntax.isComment(data, column)
-
- def isBlockComment(self, block, column):
- """Check if character at column is a block comment
- """
- dataObject = block.userData()
- data = dataObject.data if dataObject is not None else None
- return self._syntax.isBlockComment(data, column)
-
- def isHereDoc(self, block, column):
- """Check if character at column is a here document
- """
- dataObject = block.userData()
- data = dataObject.data if dataObject is not None else None
- return self._syntax.isHereDoc(data, column)
-
- @staticmethod
- def _lineData(block):
- dataObject = block.userData()
- if dataObject is not None:
- return dataObject.data
- else:
- return None
-
- def _wasChangedJustBefore(self):
- """Check if ANY Qutepart instance was changed just before"""
- return time.time() <= _gLastChangeTime + 1
-
- @pyqtSlot(int, int, int)
- def _onContentsChange(self, from_, charsRemoved, charsAdded, zeroTimeout=False):
- global _gLastChangeTime
- firstBlock = self._document.findBlock(from_)
- untilBlock = self._document.findBlock(from_ + charsAdded)
-
- if self._globalTimer.isCallbackScheduled(self._onContinueHighlighting): # have not finished task.
- """ Intersect ranges. Might produce a lot of extra highlighting work
- More complicated algorithm might be invented later
- """
- if self._pendingBlockNumber < firstBlock.blockNumber():
- firstBlock = self._document.findBlockByNumber(self._pendingBlockNumber)
- if self._pendingAtLeastUntilBlockNumber > untilBlock.blockNumber():
- untilBlockNumber = min(self._pendingAtLeastUntilBlockNumber,
- self._document.blockCount() - 1)
- untilBlock = self._document.findBlockByNumber(untilBlockNumber)
- self._globalTimer.unScheduleCallback(self._onContinueHighlighting)
-
- if zeroTimeout:
- timeout = 0 # no parsing, only schedule
- elif charsAdded > 20 and \
- (not self._wasChangedJustBefore()):
- """Use big timeout, if change is really big and previous big change was long time ago"""
- timeout = self._MAX_PARSING_TIME_BIG_CHANGE_SEC
- else:
- timeout = self._MAX_PARSING_TIME_SMALL_CHANGE_SEC
-
- _gLastChangeTime = time.time()
-
- self._highlighBlocks(firstBlock, untilBlock, timeout)
-
- def _onContinueHighlighting(self):
- self._highlighBlocks(self._document.findBlockByNumber(self._pendingBlockNumber),
- self._document.findBlockByNumber(self._pendingAtLeastUntilBlockNumber),
- self._MAX_PARSING_TIME_SMALL_CHANGE_SEC)
-
- def _highlighBlocks(self, fromBlock, atLeastUntilBlock, timeout):
- endTime = time.time() + timeout
-
- block = fromBlock
- lineData = self._lineData(block.previous())
-
- while block.isValid() and block != atLeastUntilBlock:
- if time.time() >= endTime: # time is over, schedule parsing later and release event loop
- self._pendingBlockNumber = block.blockNumber()
- self._pendingAtLeastUntilBlockNumber = atLeastUntilBlock.blockNumber()
- self._globalTimer.scheduleCallback(self._onContinueHighlighting)
- return
-
- contextStack = lineData[0] if lineData is not None else None
- if block.length() < 4096:
- lineData, highlightedSegments = self._syntax.highlightBlock(block.text(), contextStack)
- else:
- """Parser freezes for a long time, if line is too long
- invalid parsing results are still better, than freeze
- """
- lineData, highlightedSegments = None, []
- if lineData is not None:
- block.setUserData(_TextBlockUserData(lineData))
- else:
- block.setUserData(None)
-
- self._applyHighlightedSegments(block, highlightedSegments)
- block = block.next()
-
- # reached atLeastUntilBlock, now parse next only while data changed
- prevLineData = self._lineData(block)
- while block.isValid():
- if time.time() >= endTime: # time is over, schedule parsing later and release event loop
- self._pendingBlockNumber = block.blockNumber()
- self._pendingAtLeastUntilBlockNumber = atLeastUntilBlock.blockNumber()
- self._globalTimer.scheduleCallback(self._onContinueHighlighting)
- return
- contextStack = lineData[0] if lineData is not None else None
- lineData, highlightedSegments = self._syntax.highlightBlock(block.text(), contextStack)
- if lineData is not None:
- block.setUserData(_TextBlockUserData(lineData))
- else:
- block.setUserData(None)
-
- self._applyHighlightedSegments(block, highlightedSegments)
- if prevLineData == lineData:
- break
-
- block = block.next()
- prevLineData = self._lineData(block)
-
- # sucessfully finished, reset pending tasks
- self._pendingBlockNumber = None
- self._pendingAtLeastUntilBlockNumber = None
-
- """Emit sizeChanged when highlighting finished, because document size might change.
- See andreikop/enki issue #191
- """
- documentLayout = self._textEdit.document().documentLayout()
- documentLayout.documentSizeChanged.emit(documentLayout.documentSize())
-
- def _applyHighlightedSegments(self, block, highlightedSegments):
- ranges = []
- currentPos = 0
-
- for length, format in highlightedSegments:
- if format is not None: # might be in incorrect syntax file
- range = QTextLayout.FormatRange()
- range.format = format
- range.start = currentPos
- range.length = length
- ranges.append(range)
- currentPos += length
-
- if not _formatRangeListsEqual(block.layout().additionalFormats(), ranges):
- block.layout().setAdditionalFormats(ranges)
- self._document.markContentsDirty(block.position(), block.length())
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/version.py b/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/__init__.py
similarity index 94%
rename from Orange/widgets/data/utils/pythoneditor/version.py
rename to Orange/widgets/data/utils/pythoneditor/tests/test_indenter/__init__.py
index 440a726dd3a..4af8acfc7d8 100644
--- a/Orange/widgets/data/utils/pythoneditor/version.py
+++ b/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/__init__.py
@@ -7,4 +7,3 @@
as published by the Free Software Foundation, version 2.1 of the license.
This is compatible with Orange3's GPL-3.0 license.
"""
-VERSION = (3, 3, 1)
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..34217d0ed72
--- /dev/null
+++ b/Orange/widgets/data/utils/pythoneditor/tests/test_rectangular_selection.py
@@ -0,0 +1,259 @@
+"""
+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)
+
+ # 2 variants, Qt bahavior differs on different systems
+ self.assertIn(self.qpart.text, ('abcdefhh\n\tkl\n\t\tz',
+ 'abcdefh\n\tkl\n\t\t'))
+
+ 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
index 04d29e43d6c..65d45579c44 100644
--- a/Orange/widgets/data/utils/pythoneditor/vim.py
+++ b/Orange/widgets/data/utils/pythoneditor/vim.py
@@ -13,9 +13,13 @@
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
-"""
+# 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)
@@ -53,6 +57,7 @@ def code(ev):
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
"""
@@ -86,6 +91,7 @@ class _GlobalClipboard:
def __init__(self):
self.value = ''
+
_globalClipboard = _GlobalClipboard()
@@ -179,6 +185,7 @@ def _onModificationChanged(self, modified):
class Mode:
+ # pylint: disable=no-self-use
color = None
def __init__(self, vim, qpart):
@@ -226,7 +233,8 @@ def keyPressEvent(self, ev):
self._qpart.setOverwriteMode(False)
line, col = self._qpart.cursorPosition
if col > 0:
- self._qpart.cursorPosition = (line, col - 1) # return the cursor back after replacement
+ # return the cursor back after replacement
+ self._qpart.cursorPosition = (line, col - 1)
self.switchMode(Normal)
return True
else:
@@ -257,6 +265,7 @@ def keyPressEvent(self, ev):
class BaseCommandMode(Mode):
""" Base class for Normal and Visual modes
"""
+
def __init__(self, *args):
Mode.__init__(self, *args)
self._reset()
@@ -334,8 +343,7 @@ def _moveCursor(self, motion, count, searchChar=None, select=False):
_Home: QTextCursor.StartOfBlock,
'gg': QTextCursor.Start,
_G: QTextCursor.End
- }
-
+ }
if motion == _G:
if count == 0: # default - go to the end
@@ -361,9 +369,10 @@ def _moveCursor(self, motion, count, searchChar=None, select=False):
break
if cursor.positionInBlock() == len(text): # at the end of line
- cursor.movePosition(QTextCursor.NextCharacter, moveMode) # move to the next line
+ # move to the next line
+ cursor.movePosition(QTextCursor.NextCharacter, moveMode)
- # now move to the end of word
+ # now move to the end of word
if motion == _e:
cursor.movePosition(QTextCursor.EndOfWord, moveMode)
else:
@@ -377,21 +386,22 @@ def _moveCursor(self, motion, count, searchChar=None, select=False):
elif motion == _B:
cursor.movePosition(QTextCursor.WordLeft, moveMode)
while cursor.positionInBlock() != 0 and \
- (not cursor.block().text()[cursor.positionInBlock() - 1].isspace()):
+ (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()):
+ (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
+ ((startBlock, startCol), (endBlock, endCol)) = \
+ self._qpart._bracketHighlighter.currentMatchedBrackets
startPos = startBlock.position() + startCol
endPos = endBlock.position() + endCol
if select and \
- (endPos > startPos):
+ (endPos > startPos):
endPos += 1 # to select the bracket, not only chars before it
cursor.setPosition(endPos, moveMode)
elif motion == _Caret:
@@ -432,7 +442,8 @@ def _moveCursor(self, motion, count, searchChar=None, select=False):
self._qpart.setTextCursor(cursor)
- def _iterateDocumentCharsForward(self, block, startColumnIndex):
+ @staticmethod
+ def _iterateDocumentCharsForward(block, startColumnIndex):
"""Traverse document forward. Yield (block, columnIndex, char)
Raise _TimeoutException if time is over
"""
@@ -448,7 +459,8 @@ def _iterateDocumentCharsForward(self, block, startColumnIndex):
block = block.next()
- def _iterateDocumentCharsBackward(self, block, startColumnIndex):
+ @staticmethod
+ def _iterateDocumentCharsBackward(block, startColumnIndex):
"""Traverse document forward. Yield (block, columnIndex, char)
Raise _TimeoutException if time is over
"""
@@ -477,7 +489,6 @@ def _expandSelection(self):
anchor = cursor.anchor()
pos = cursor.position()
-
if pos >= anchor:
anchorSide = QTextCursor.StartOfBlock
cursorSide = QTextCursor.EndOfBlock
@@ -485,7 +496,6 @@ def _expandSelection(self):
anchorSide = QTextCursor.EndOfBlock
cursorSide = QTextCursor.StartOfBlock
-
cursor.setPosition(anchor)
cursor.movePosition(anchorSide)
cursor.setPosition(pos, QTextCursor.KeepAnchor)
@@ -494,8 +504,6 @@ def _expandSelection(self):
self._qpart.setTextCursor(cursor)
-
-
class BaseVisual(BaseCommandMode):
color = QColor('#6699ff')
_selectLines = NotImplementedError()
@@ -554,15 +562,14 @@ def _processChar(self):
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
- """
+ # 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)):
+ (action in (_j, _Down) and (cursorLine + 1) < len(self._qpart.lines)):
while self._qpart.cursorPosition[0] == cursorLine:
self._moveCursor(action, typedCount, select=True)
else:
@@ -576,7 +583,7 @@ def _processChar(self):
newChar = ev.text()
if newChar:
newChars = [newChar if char != '\n' else '\n' \
- for char in self._qpart.selectedText
+ for char in self._qpart.selectedText
]
newText = ''.join(newChars)
self._qpart.selectedText = newText
@@ -592,7 +599,7 @@ def _processChar(self):
def _selectedLinesRange(self):
""" Selected lines range for line manipulation methods
"""
- (startLine, startCol), (endLine, endCol) = self._qpart.selectedPosition
+ (startLine, _), (endLine, _) = self._qpart.selectedPosition
start = min(startLine, endLine)
end = max(startLine, endLine)
return start, end
@@ -602,7 +609,8 @@ def _selectRangeForRepeat(self, repeatLineCount):
self._qpart.selectedPosition = ((start, 0),
(start + repeatLineCount - 1, 0))
cursor = self._qpart.textCursor()
- cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) # expand until the end of line
+ # expand until the end of line
+ cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor)
self._qpart.setTextCursor(cursor)
def _saveLastEditLinesCmd(self, cmd, lineCount):
@@ -619,7 +627,7 @@ def cmdDelete(self, cmd, repeatLineCount=None):
cursor = self._qpart.textCursor()
if cursor.selectedText():
if self._selectLines:
- start, end = self._selectedLinesRange()
+ 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]
@@ -631,7 +639,7 @@ def cmdDeleteLines(self, cmd, repeatLineCount=None):
if repeatLineCount is not None:
self._selectRangeForRepeat(repeatLineCount)
- start, end = self._selectedLinesRange()
+ start, end = self._selectedLinesRange()
self._saveLastEditLinesCmd(cmd, end - start + 1)
_globalClipboard.value = self._qpart.lines[start:end + 1]
@@ -743,7 +751,7 @@ def cmdUnIndent(self, cmd, repeatLineCount=None):
if repeatLineCount is not None:
self._selectRangeForRepeat(repeatLineCount)
else:
- start, end = self._selectedLinesRange()
+ start, end = self._selectedLinesRange()
self._saveLastEditLinesCmd(cmd, end - start + 1)
self._qpart._indenter.onChangeSelectedBlocksIndent(increase=False, withSpace=False)
@@ -755,7 +763,7 @@ def cmdIndent(self, cmd, repeatLineCount=None):
if repeatLineCount is not None:
self._selectRangeForRepeat(repeatLineCount)
else:
- start, end = self._selectedLinesRange()
+ start, end = self._selectedLinesRange()
self._saveLastEditLinesCmd(cmd, end - start + 1)
self._qpart._indenter.onChangeSelectedBlocksIndent(increase=True, withSpace=False)
@@ -767,7 +775,7 @@ def cmdAutoIndent(self, cmd, repeatLineCount=None):
if repeatLineCount is not None:
self._selectRangeForRepeat(repeatLineCount)
else:
- start, end = self._selectedLinesRange()
+ start, end = self._selectedLinesRange()
self._saveLastEditLinesCmd(cmd, end - start + 1)
self._qpart._indenter.onAutoIndentTriggered()
@@ -776,27 +784,27 @@ def cmdAutoIndent(self, cmd, repeatLineCount=None):
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,
- }
+ _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):
@@ -900,12 +908,12 @@ def _processChar(self):
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)):
+ (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)
@@ -915,7 +923,6 @@ def _processChar(self):
else:
return False # but do not ignore not-a-character keys
-
assert 0 # must StopIteration on if
def _repeat(self, count, func):
@@ -1017,12 +1024,14 @@ def cmdNewLineBelow(self, cmd, count):
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)
@@ -1041,7 +1050,7 @@ def cmdInternalPaste(self, cmd, count):
self._qpart.setTextCursor(cursor)
self._repeat(count,
- lambda: cursor.insertText(_globalClipboard.value))
+ lambda: cursor.insertText(_globalClipboard.value))
cursor.movePosition(QTextCursor.Left)
self._qpart.setTextCursor(cursor)
@@ -1051,7 +1060,7 @@ def cmdInternalPaste(self, cmd, count):
index += 1
self._repeat(count,
- lambda: self._qpart.lines.insert(index, '\n'.join(_globalClipboard.value)))
+ lambda: self._qpart.lines.insert(index, '\n'.join(_globalClipboard.value)))
self._saveLastEditSimpleCmd(cmd, count)
@@ -1132,7 +1141,6 @@ def cmdYankUntilEndOfLine(self, cmd, count):
self._qpart.copy()
self._qpart.setTextCursor(oldCursor)
-
_SIMPLE_COMMANDS = {_A: cmdAppendAfterLine,
_a: cmdAppendAfterChar,
_C: cmdDeleteUntilEndOfBlock,
@@ -1155,7 +1163,7 @@ def cmdYankUntilEndOfLine(self, cmd, count):
_x: cmdDelete,
_X: cmdDelete,
_Y: cmdYankUntilEndOfLine,
- }
+ }
#
# Composite commands
@@ -1276,4 +1284,4 @@ def cmdCompositeScrollView(self, cmd, motion, searchChar, count):
_Greater: cmdCompositeIndent,
_Equal: cmdCompositeAutoIndent,
_z: cmdCompositeScrollView,
- }
+ }
From feb1747f80411fe133cfc6b4515065645b0643c3 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Sun, 24 Jan 2021 00:17:29 +0000
Subject: [PATCH 07/44] owpythonscript: Replace editor for ported QutePart
---
Orange/widgets/data/owpythonscript.py | 134 ++++++++++++++------------
1 file changed, 71 insertions(+), 63 deletions(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 8b402599207..6f124e3d7ee 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -10,6 +10,10 @@
from typing import Optional, List, TYPE_CHECKING
+import pygments.style
+from pygments.token import Comment, Keyword, Number, String, Punctuation, Operator, Error, Name
+
+
from AnyQt.QtWidgets import (
QPlainTextEdit, QListView, QSizePolicy, QMenu, QSplitter, QLineEdit,
QAction, QToolButton, QFileDialog, QStyledItemDelegate,
@@ -27,6 +31,7 @@
from Orange.base import Learner, Model
from Orange.util import interleave
from Orange.widgets import gui
+from Orange.widgets.data.utils.pythoneditor.editor import PythonEditor
from Orange.widgets.utils import itemmodels
from Orange.widgets.settings import Setting
from Orange.widgets.utils.widgetpreview import WidgetPreview
@@ -66,6 +71,59 @@ def read_file_content(filename, limit=None):
return None
+"""
+Adapted from jupyter notebook, which was adapted from GitHub.
+
+Highlighting styles are applied with pygments.
+
+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': {
+ Error: '#f00',
+
+ Keyword: 'bold #008000',
+
+ Name: '#212121',
+ Name.Function: '#00f',
+ Name.Variable: '#05a',
+ Name.Decorator: '#aa22ff',
+ Name.Builtin: '#008000',
+ Name.Builtin.Pseudo: '#05a',
+
+ String: '#ba2121',
+
+ Number: '#080',
+
+ Operator: 'bold #aa22ff',
+ Operator.Word: 'bold #008000',
+
+ Comment: 'italic #408080',
+ },
+ 'Dark': {
+ # TODO
+ }
+}
+
+
+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]}
+ )
+
+
+PygmentsStyle = make_pygments_style('Light')
+
+
class PythonSyntaxHighlighter(QSyntaxHighlighter):
def __init__(self, parent=None):
@@ -131,67 +189,6 @@ def highlightBlock(self, text):
)
-class PythonScriptEditor(QPlainTextEdit):
- INDENT = 4
-
- def __init__(self, widget):
- super().__init__()
- self.widget = widget
-
- 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
-
- 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)
-
- 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
@@ -553,10 +550,21 @@ def __init__(self):
self.mainArea.layout().addWidget(self.splitCanvas)
self.defaultFont = defaultFont = \
- "Monaco" if sys.platform == "darwin" else "Courier"
+ "Menlo" if sys.platform == "darwin" else "Courier"
+ self.defaultFontSize = defaultFontSize = 13
self.textBox = gui.vBox(self.splitCanvas, 'Python Script')
- self.text = PythonScriptEditor(self)
+ self.splitCanvas.addWidget(self.textBox)
+
+ syntax_highlighting_scheme = SYNTAX_HIGHLIGHTING_STYLES['Light']
+
+ eFont = QFont(defaultFont)
+ eFont.setPointSize(defaultFontSize)
+
+ editor = PythonEditor(self)
+ editor.setFont(eFont)
+
+ self.text = editor
self.textBox.layout().addWidget(self.text)
self.textBox.setAlignment(Qt.AlignVCenter)
From 3d350116f1c81355c9c5bd4bb5b646c307ed58b1 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Sun, 24 Jan 2021 00:27:48 +0000
Subject: [PATCH 08/44] owpythonscript: Remove namespaces
I can't run WidgetPreview without this removed.
It'll be replaced by a dedicated kernel soon.
---
Orange/widgets/data/owpythonscript.py | 21 +-------
.../widgets/data/tests/test_owpythonscript.py | 48 ++++++++-----------
2 files changed, 22 insertions(+), 47 deletions(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 6f124e3d7ee..00eea107a7d 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -4,8 +4,6 @@
import keyword
import itertools
import unicodedata
-import weakref
-from functools import reduce
from unittest.mock import patch
from typing import Optional, List, TYPE_CHECKING
@@ -446,14 +444,6 @@ 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()
-
class Error(OWWidget.Error):
pass
@@ -748,7 +738,7 @@ def saveScript(self):
f.close()
def initial_locals_state(self):
- d = self.shared_namespaces.setdefault(self.signalManager, {}).copy()
+ d = {}
for name in self.signal_names:
value = getattr(self, name)
all_values = list(value.values())
@@ -757,14 +747,6 @@ def initial_locals_state(self):
d["in_" + name] = one_value
return d
- def update_namespace(self, namespace):
- not_saved = reduce(set.union,
- ({f"in_{name}s", f"in_{name}", f"out_{name}"}
- for name in self.signal_names))
- self.shared_namespaces.setdefault(self.signalManager, {}).update(
- {name: value for name, value in namespace.items()
- if name not in not_saved})
-
def commit(self):
self.Error.clear()
lcls = self.initial_locals_state()
@@ -773,7 +755,6 @@ def commit(self):
self.console.write("\nRunning script:\n")
self.console.push("exec(_script)")
self.console.new_prompt(sys.ps1)
- self.update_namespace(self.console.locals)
for signal in self.signal_names:
out_var = self.console.locals.get("out_" + signal)
signal_type = getattr(self.Outputs, signal).type
diff --git a/Orange/widgets/data/tests/test_owpythonscript.py b/Orange/widgets/data/tests/test_owpythonscript.py
index 6c505737e76..05b04c5d7cb 100644
--- a/Orange/widgets/data/tests/test_owpythonscript.py
+++ b/Orange/widgets/data/tests/test_owpythonscript.py
@@ -220,33 +220,6 @@ def _drop_event(self, url):
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")],
@@ -260,3 +233,24 @@ def test_restore(self):
"__version__": 2
})
self.assertEqual(w.libraryListSource[0].name, "A")
+
+ def test_no_shared_namespaces(self):
+ """
+ Previously, Python Script widgets in the same schema shared a namespace.
+ I (irgolic) think this is just a way to encourage users in writing
+ messy workflows with race conditions, so I encourage them to share
+ between Python Script widgets with Object signals.
+ """
+ widget1 = self.create_widget(OWPythonScript)
+ widget2 = self.create_widget(OWPythonScript)
+
+ click1 = widget1.execute_button.click
+ click2 = widget2.execute_button.click
+
+ widget1.text.text = "x = 42"
+ click1()
+
+ widget2.text.text = "y = 2 * x"
+ click2()
+ self.assertIn("NameError: name 'x' is not defined",
+ widget2.console.toPlainText())
From 458857a26d3b916e816c60fe2d1f883a67598201 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Sun, 24 Jan 2021 01:36:01 +0000
Subject: [PATCH 09/44] owpythonscript: Remove infobox
---
Orange/widgets/data/owpythonscript.py | 10 ----------
1 file changed, 10 deletions(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 00eea107a7d..20a224df2b8 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -456,16 +456,6 @@ def __init__(self):
self._cachedDocuments = {}
- 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.libraryList = itemmodels.PyListModel(
[], self,
flags=Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsEditable)
From 9ac6bf91bf30b8b46c304af0abe8b6064346a81b Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Sun, 24 Jan 2021 01:57:04 +0000
Subject: [PATCH 10/44] owpythonscript: Add fake function signature
---
Orange/widgets/data/owpythonscript.py | 99 ++++++++++++++++++++++++++-
1 file changed, 96 insertions(+), 3 deletions(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 20a224df2b8..1b9ca518ece 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -15,11 +15,12 @@
from AnyQt.QtWidgets import (
QPlainTextEdit, QListView, QSizePolicy, QMenu, QSplitter, QLineEdit,
QAction, QToolButton, QFileDialog, QStyledItemDelegate,
- QStyleOptionViewItem, QPlainTextDocumentLayout
-)
+ QStyleOptionViewItem, QPlainTextDocumentLayout,
+ QLabel, QWidget, QHBoxLayout)
from AnyQt.QtGui import (
QColor, QBrush, QPalette, QFont, QTextDocument,
QSyntaxHighlighter, QTextCharFormat, QTextCursor, QKeySequence,
+ QFontMetrics
)
from AnyQt.QtCore import (
Qt, QRegularExpression, QByteArray, QItemSelectionModel, QSize
@@ -187,6 +188,65 @@ def highlightBlock(self, 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 PythonConsole(QPlainTextEdit, code.InteractiveConsole):
# `locals` is reasonably used as argument name
# pylint: disable=redefined-builtin
@@ -529,6 +589,8 @@ def __init__(self):
self.splitCanvas = QSplitter(Qt.Vertical, self.mainArea)
self.mainArea.layout().addWidget(self.splitCanvas)
+ # Styling
+
self.defaultFont = defaultFont = \
"Menlo" if sys.platform == "darwin" else "Courier"
self.defaultFontSize = defaultFontSize = 13
@@ -541,11 +603,37 @@ def __init__(self):
eFont = QFont(defaultFont)
eFont.setPointSize(defaultFontSize)
+ # Fake Signature
+
+ self.func_sig = func_sig = FunctionSignature(
+ self.textBox,
+ syntax_highlighting_scheme,
+ eFont
+ )
+ self.textBox.layout().addWidget(func_sig)
+
+ # Editor
+
editor = PythonEditor(self)
editor.setFont(eFont)
+ # Match indentation
+
+ textEditBox = QWidget(self.textBox)
+ textEditBox.setLayout(QHBoxLayout())
+ char_4_width = QFontMetrics(eFont).width('0000')
+
+ @editor.viewport_margins_updated.connect
+ def _(width):
+ func_sig.setIndent(width)
+ textEditMargin = max(0, char_4_width - width)
+ textEditBox.layout().setContentsMargins(
+ textEditMargin, 0, 0, 0
+ )
+
self.text = editor
- self.textBox.layout().addWidget(self.text)
+ textEditBox.layout().addWidget(editor)
+ self.textBox.layout().addWidget(textEditBox)
self.textBox.setAlignment(Qt.AlignVCenter)
@@ -616,6 +704,11 @@ def set_object(self, data, sig_id):
self.handle_input(data, sig_id, "object")
def handleNewSignals(self):
+ # update fake signature labels
+ self.func_sig.update_signal_text({
+ n: len(getattr(self, n)) for n in self.signal_names
+ })
+
self.commit()
def selectedScriptIndex(self):
From 831a818736f4417d7bc4fe4b64ab79f5fb7e7986 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Sun, 24 Jan 2021 02:25:00 +0000
Subject: [PATCH 11/44] owpythonscript: Add vim mode option/indicator
---
Orange/widgets/data/owpythonscript.py | 175 +++++++++++++++++---------
1 file changed, 119 insertions(+), 56 deletions(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 1b9ca518ece..985491b1de7 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -20,10 +20,10 @@
from AnyQt.QtGui import (
QColor, QBrush, QPalette, QFont, QTextDocument,
QSyntaxHighlighter, QTextCharFormat, QTextCursor, QKeySequence,
- QFontMetrics
+ QFontMetrics, QPainter
)
from AnyQt.QtCore import (
- Qt, QRegularExpression, QByteArray, QItemSelectionModel, QSize
+ Qt, QRegularExpression, QByteArray, QItemSelectionModel, QSize, QRectF
)
from Orange.data import Table
@@ -247,6 +247,37 @@ def update_signal_text(self, signal_values_lengths):
self.update()
+class VimIndicator(QWidget):
+ def __init__(self, parent):
+ super().__init__(parent)
+ self.indicator_color = QColor('#33cc33')
+ self.indicator_text = 'normal'
+
+ def paintEvent(self, event):
+ super().paintEvent(event)
+ p = QPainter(self)
+ p.setRenderHint(QPainter.Antialiasing)
+ p.setBrush(self.indicator_color)
+
+ p.save()
+ p.setPen(Qt.NoPen)
+ fm = QFontMetrics(self.font())
+ width = self.rect().width()
+ height = fm.height() + 6
+ rect = QRectF(0, 0, width, height)
+ p.drawRoundedRect(rect, 5, 5)
+ p.restore()
+
+ textstart = (width - fm.width(self.indicator_text)) / 2
+ p.drawText(textstart, height / 2 + 5, self.indicator_text)
+
+ def minimumSizeHint(self):
+ fm = QFontMetrics(self.font())
+ width = round(fm.width(self.indicator_text)) + 10
+ height = fm.height() + 6
+ return QSize(width, height)
+
+
class PythonConsole(QPlainTextEdit, code.InteractiveConsole):
# `locals` is reasonably used as argument name
# pylint: disable=redefined-builtin
@@ -504,16 +535,101 @@ class Outputs:
scriptText: Optional[str] = Setting(None, schema_only=True)
splitterState: Optional[bytes] = Setting(None)
+ vimModeEnabled = Setting(False)
+
class Error(OWWidget.Error):
pass
def __init__(self):
super().__init__()
- self.libraryListSource = []
for name in self.signal_names:
setattr(self, name, {})
+ self.splitCanvas = QSplitter(Qt.Vertical, self.mainArea)
+ self.mainArea.layout().addWidget(self.splitCanvas)
+
+ # Styling
+
+ self.defaultFont = defaultFont = \
+ "Menlo" if sys.platform == "darwin" else "Courier"
+ self.defaultFontSize = defaultFontSize = 13
+
+ self.textBox = gui.vBox(self, box=True)
+ self.splitCanvas.addWidget(self.textBox)
+
+ syntax_highlighting_scheme = SYNTAX_HIGHLIGHTING_STYLES['Light']
+
+ eFont = QFont(defaultFont)
+ eFont.setPointSize(defaultFontSize)
+
+ # Fake Signature
+
+ self.func_sig = func_sig = FunctionSignature(
+ self.textBox,
+ syntax_highlighting_scheme,
+ eFont
+ )
+ self.textBox.layout().addWidget(func_sig)
+
+ # Editor
+
+ editor = PythonEditor(self)
+ editor.setFont(eFont)
+
+ # Match indentation
+
+ textEditBox = QWidget(self.textBox)
+ textEditBox.setLayout(QHBoxLayout())
+ char_4_width = QFontMetrics(eFont).horizontalAdvance('0000')
+
+ @editor.viewport_margins_updated.connect
+ def _(width):
+ func_sig.setIndent(width)
+ textEditMargin = max(0, char_4_width - width)
+ textEditBox.layout().setContentsMargins(
+ textEditMargin, 0, 0, 0
+ )
+
+ self.text = editor
+ textEditBox.layout().addWidget(editor)
+ self.textBox.layout().addWidget(textEditBox)
+
+ self.textBox.setAlignment(Qt.AlignVCenter)
+ self.text.setTabStopWidth(4)
+
+ self.text.modificationChanged[bool].connect(self.onModificationChanged)
+
+ # Controls
+
+ self.editor_controls = gui.vBox(self.controlArea, box=True)
+
+ self.vim_box = gui.hBox(self.editor_controls, spacing=20)
+ self.vim_indicator = VimIndicator(self.vim_box)
+ self.vim_indicator.setSizePolicy(
+ QSizePolicy.Expanding, QSizePolicy.Fixed
+ )
+
+ def enable_vim_mode():
+ editor.vimModeEnabled = self.vimModeEnabled
+ self.vim_indicator.setVisible(self.vimModeEnabled)
+ enable_vim_mode()
+
+ gui.checkBox(
+ self.vim_box, self, 'vimModeEnabled', 'Vim mode',
+ tooltip="Only for the coolest.",
+ callback=enable_vim_mode
+ )
+ self.vim_box.layout().addWidget(self.vim_indicator)
+ @editor.vimModeIndicationChanged.connect
+ def _(color, text):
+ self.vim_indicator.indicator_color = color
+ self.vim_indicator.indicator_text = text
+ self.vim_indicator.update()
+
+ # Library
+
+ self.libraryListSource = []
self._cachedDocuments = {}
self.libraryList = itemmodels.PyListModel(
@@ -586,59 +702,6 @@ def __init__(self):
shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_R))
self.addAction(run)
- self.splitCanvas = QSplitter(Qt.Vertical, self.mainArea)
- self.mainArea.layout().addWidget(self.splitCanvas)
-
- # Styling
-
- self.defaultFont = defaultFont = \
- "Menlo" if sys.platform == "darwin" else "Courier"
- self.defaultFontSize = defaultFontSize = 13
-
- self.textBox = gui.vBox(self.splitCanvas, 'Python Script')
- self.splitCanvas.addWidget(self.textBox)
-
- syntax_highlighting_scheme = SYNTAX_HIGHLIGHTING_STYLES['Light']
-
- eFont = QFont(defaultFont)
- eFont.setPointSize(defaultFontSize)
-
- # Fake Signature
-
- self.func_sig = func_sig = FunctionSignature(
- self.textBox,
- syntax_highlighting_scheme,
- eFont
- )
- self.textBox.layout().addWidget(func_sig)
-
- # Editor
-
- editor = PythonEditor(self)
- editor.setFont(eFont)
-
- # Match indentation
-
- textEditBox = QWidget(self.textBox)
- textEditBox.setLayout(QHBoxLayout())
- char_4_width = QFontMetrics(eFont).width('0000')
-
- @editor.viewport_margins_updated.connect
- def _(width):
- func_sig.setIndent(width)
- textEditMargin = max(0, char_4_width - width)
- textEditBox.layout().setContentsMargins(
- textEditMargin, 0, 0, 0
- )
-
- self.text = editor
- textEditBox.layout().addWidget(editor)
- self.textBox.layout().addWidget(textEditBox)
-
- self.textBox.setAlignment(Qt.AlignVCenter)
-
- self.text.modificationChanged[bool].connect(self.onModificationChanged)
-
self.saveAction = action = QAction("&Save", self.text)
action.setToolTip("Save script to file")
action.setShortcut(QKeySequence(QKeySequence.Save))
From a5ef8f6cd0f9e2ceb321c70cbc5b5d912b0095dc Mon Sep 17 00:00:00 2001
From: Rafael
Date: Fri, 7 Feb 2020 21:50:02 +0000
Subject: [PATCH 12/44] owpythonscript: Detect encoding onAddScriptFromFile
---
Orange/widgets/data/owpythonscript.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 985491b1de7..0f0d35f5384 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -3,6 +3,7 @@
import code
import keyword
import itertools
+import tokenize
import unicodedata
from unittest.mock import patch
@@ -643,7 +644,7 @@ def _(color, text):
self.libraryView = QListView(
editTriggers=QListView.DoubleClicked |
- QListView.EditKeyPressed,
+ QListView.EditKeyPressed,
sizePolicy=QSizePolicy(QSizePolicy.Ignored,
QSizePolicy.Preferred)
)
@@ -796,8 +797,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)
From 73d0c37a9f2f3043a47a42d784e3cda486d97514 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Sat, 14 Mar 2020 14:15:56 +0000
Subject: [PATCH 13/44] owpythonscript: Remove 'file' keyword
---
Orange/widgets/data/owpythonscript.py | 6 +++---
doc/widgets.json | 1 -
2 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 0f0d35f5384..bc1bd8d9be9 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -71,6 +71,7 @@ def read_file_content(filename, limit=None):
return None
+# pylint: disable=pointless-string-statement
"""
Adapted from jupyter notebook, which was adapted from GitHub.
@@ -506,7 +507,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"],
@@ -643,8 +644,7 @@ def _(color, text):
self.controlBox.layout().setSpacing(1)
self.libraryView = QListView(
- editTriggers=QListView.DoubleClicked |
- QListView.EditKeyPressed,
+ editTriggers=QListView.DoubleClicked | QListView.EditKeyPressed,
sizePolicy=QSizePolicy(QSizePolicy.Ignored,
QSizePolicy.Preferred)
)
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"
]
From ff7ac71608e57506fa4c7f4037f629dbbb2497f7 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Sun, 24 Jan 2021 18:38:25 +0000
Subject: [PATCH 14/44] requirements-gui: Require qtconsole
For now only uses its pygments stuff in editor.
A later PR will implement more with this requirement.
---
requirements-gui.txt | 1 +
1 file changed, 1 insertion(+)
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
From 01b46fb57e013a049cbae381978dde6ee0084b98 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Sun, 24 Jan 2021 21:10:57 +0000
Subject: [PATCH 15/44] owpythonscript: Remove obsolete dropEvent
---
Orange/widgets/data/owpythonscript.py | 7 -----
.../widgets/data/tests/test_owpythonscript.py | 27 +++++--------------
2 files changed, 7 insertions(+), 27 deletions(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index bc1bd8d9be9..1038d30301d 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -715,7 +715,6 @@ def _(color, text):
self.console.document().setDefaultFont(QFont(defaultFont))
self.consoleBox.setAlignment(Qt.AlignBottom)
self.splitCanvas.setSizes([2, 1])
- self.setAcceptDrops(True)
self.controlArea.layout().addStretch(10)
self._restoreState()
@@ -920,12 +919,6 @@ def dragEnterEvent(self, event): # pylint: disable=no-self-use
if c is not None:
event.acceptProposedAction()
- def dropEvent(self, event):
- """Handle file drops"""
- urls = event.mimeData().urls()
- if urls:
- self.text.pasteFile(urls[0])
-
@classmethod
def migrate_settings(cls, settings, version):
if version is not None and version < 2:
diff --git a/Orange/widgets/data/tests/test_owpythonscript.py b/Orange/widgets/data/tests/test_owpythonscript.py
index 05b04c5d7cb..1876e1ac10b 100644
--- a/Orange/widgets/data/tests/test_owpythonscript.py
+++ b/Orange/widgets/data/tests/test_owpythonscript.py
@@ -1,5 +1,7 @@
# Test methods with long descriptive names can omit docstrings
# pylint: disable=missing-docstring
+import os
+
import sys
from AnyQt.QtCore import QMimeData, QUrl, QPoint, Qt
@@ -174,7 +176,11 @@ def test_script_insert_mime_file(self):
url = QUrl.fromLocalFile(fn)
mime.setUrls([url])
self.widget.text.insertFromMimeData(mime)
- self.assertEqual("test", self.widget.text.toPlainText())
+ text = self.widget.text.toPlainText().split("print('Hello world')")[0]
+ self.assertTrue(
+ "'" + fn + "'",
+ text
+ )
self.widget.text.undo()
self.assertEqual(previous, self.widget.text.toPlainText())
@@ -201,25 +207,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_migrate(self):
w = self.create_widget(OWPythonScript, {
"libraryListSource": [Script("A", "1")],
From 9950a9170de61dc2dd1f17ee9061d06e36817901 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Mon, 25 Jan 2021 00:40:08 +0000
Subject: [PATCH 16/44] test_owpythonscript: Include editor tests
---
Orange/widgets/data/tests/test_owpythonscript.py | 14 ++++++++++++++
.../data/utils/pythoneditor/tests/__init__.py | 0
2 files changed, 14 insertions(+)
create mode 100644 Orange/widgets/data/utils/pythoneditor/tests/__init__.py
diff --git a/Orange/widgets/data/tests/test_owpythonscript.py b/Orange/widgets/data/tests/test_owpythonscript.py
index 1876e1ac10b..69494b95b49 100644
--- a/Orange/widgets/data/tests/test_owpythonscript.py
+++ b/Orange/widgets/data/tests/test_owpythonscript.py
@@ -14,6 +14,16 @@
from Orange.widgets.tests.base import WidgetTest, DummySignalManager
from Orange.widgets.widget import OWWidget
+# import tests for python editor
+from Orange.widgets.data.utils.pythoneditor.tests.test_api import *
+from Orange.widgets.data.utils.pythoneditor.tests.test_bracket_highlighter import *
+from Orange.widgets.data.utils.pythoneditor.tests.test_draw_whitespace import *
+from Orange.widgets.data.utils.pythoneditor.tests.test_edit import *
+from Orange.widgets.data.utils.pythoneditor.tests.test_indent import *
+from Orange.widgets.data.utils.pythoneditor.tests.test_indenter.test_python import *
+from Orange.widgets.data.utils.pythoneditor.tests.test_rectangular_selection import *
+from Orange.widgets.data.utils.pythoneditor.tests.test_vim import *
+
class TestOWPythonScript(WidgetTest):
def setUp(self):
@@ -241,3 +251,7 @@ def test_no_shared_namespaces(self):
click2()
self.assertIn("NameError: name 'x' is not defined",
widget2.console.toPlainText())
+
+
+if __name__ == '__main__':
+ unittest.main()
\ No newline at end of file
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
From f2f88f9bd52aaebb88cd25c5a6c41f5ad9da5675 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Mon, 1 Feb 2021 00:36:50 +0000
Subject: [PATCH 17/44] owpythonscript: Add fake return statement
---
Orange/widgets/data/owpythonscript.py | 92 ++++++++++++++++++++++++---
1 file changed, 84 insertions(+), 8 deletions(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 1038d30301d..8edc38918b1 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -249,6 +249,70 @@ def update_signal_text(self, signal_values_lengths):
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)
@@ -557,8 +621,8 @@ def __init__(self):
"Menlo" if sys.platform == "darwin" else "Courier"
self.defaultFontSize = defaultFontSize = 13
- self.textBox = gui.vBox(self, box=True)
- self.splitCanvas.addWidget(self.textBox)
+ self.editorBox = gui.vBox(self, box=True, spacing=4)
+ self.splitCanvas.addWidget(self.editorBox)
syntax_highlighting_scheme = SYNTAX_HIGHLIGHTING_STYLES['Light']
@@ -568,36 +632,48 @@ def __init__(self):
# Fake Signature
self.func_sig = func_sig = FunctionSignature(
- self.textBox,
+ self.editorBox,
syntax_highlighting_scheme,
eFont
)
- self.textBox.layout().addWidget(func_sig)
# 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.textBox)
+ 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, char_4_width - width)
+ textEditMargin = max(0, round(char_4_width - width))
+ return_stmt.setIndent(textEditMargin + width)
textEditBox.layout().setContentsMargins(
textEditMargin, 0, 0, 0
)
self.text = editor
textEditBox.layout().addWidget(editor)
- self.textBox.layout().addWidget(textEditBox)
+ self.editorBox.layout().addWidget(func_sig)
+ self.editorBox.layout().addWidget(textEditBox)
+ self.editorBox.layout().addWidget(return_stmt)
- self.textBox.setAlignment(Qt.AlignVCenter)
+ self.editorBox.setAlignment(Qt.AlignVCenter)
self.text.setTabStopWidth(4)
self.text.modificationChanged[bool].connect(self.onModificationChanged)
From 9010bd2c279359e7961b7dc69b563bc8cf5507cc Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Mon, 1 Feb 2021 00:50:01 +0000
Subject: [PATCH 18/44] owpythonscript: Correctly set editor font
---
Orange/widgets/data/owpythonscript.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 8edc38918b1..1039febc8f7 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -908,6 +908,7 @@ def documentForScript(self, script=0):
doc.setPlainText(script.script)
doc.setDefaultFont(QFont(self.defaultFont))
doc.highlighter = PythonSyntaxHighlighter(doc)
+ doc.setDefaultFont(QFont(self.defaultFont, pointSize=self.defaultFontSize))
doc.modificationChanged[bool].connect(self.onModificationChanged)
doc.setModified(False)
self._cachedDocuments[script] = doc
From 5824efee2e9fb1d4289625b77b70fcd4c5ef3d18 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Mon, 1 Feb 2021 16:56:57 +0000
Subject: [PATCH 19/44] owpythonscript: Run on Shift+Enter
---
Orange/widgets/data/owpythonscript.py | 14 +++++++++++---
1 file changed, 11 insertions(+), 3 deletions(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 1039febc8f7..00a1bcfe5d6 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -775,9 +775,9 @@ def _(color, text):
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.run_action = QAction("Run script", self, triggered=self.commit,
+ shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_R))
+ self.addAction(self.run_action)
self.saveAction = action = QAction("&Save", self.text)
action.setToolTip("Save script to file")
@@ -988,6 +988,14 @@ def commit(self):
out_var = None
getattr(self.Outputs, signal).send(out_var)
+ 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()
if urls:
From b270b6f0f473a1d771350f7e3cacf43d1fba2d81 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Mon, 1 Feb 2021 21:24:25 +0000
Subject: [PATCH 20/44] pylint
---
Orange/widgets/data/owselectcolumns.py | 3 ++-
Orange/widgets/data/tests/test_owpythonscript.py | 12 ++++--------
2 files changed, 6 insertions(+), 9 deletions(-)
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 69494b95b49..2709d60ac67 100644
--- a/Orange/widgets/data/tests/test_owpythonscript.py
+++ b/Orange/widgets/data/tests/test_owpythonscript.py
@@ -1,17 +1,13 @@
# Test methods with long descriptive names can omit docstrings
-# pylint: disable=missing-docstring
-import os
-
-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.widget import OWWidget
# import tests for python editor
@@ -254,4 +250,4 @@ def test_no_shared_namespaces(self):
if __name__ == '__main__':
- unittest.main()
\ No newline at end of file
+ unittest.main()
From 9132c87329e1bb87e5eef33fdc2ff34acf447ea7 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Tue, 2 Feb 2021 20:33:14 +0000
Subject: [PATCH 21/44] owpythonscript: Set DejaVu Sans Mono font on linux
---
Orange/widgets/data/owpythonscript.py | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 00a1bcfe5d6..6f2e89a9a21 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -617,8 +617,11 @@ def __init__(self):
# Styling
- self.defaultFont = defaultFont = \
- "Menlo" if sys.platform == "darwin" else "Courier"
+ 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=True, spacing=4)
From ccce0fb414017b53c2d5dc5e059781311bd63d9e Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Wed, 14 Jul 2021 03:06:58 +0100
Subject: [PATCH 22/44] owpythonscript: Keep vim indicator size when hidden
---
Orange/widgets/data/owpythonscript.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 6f2e89a9a21..6a8363d815e 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -687,9 +687,12 @@ def _(width):
self.vim_box = gui.hBox(self.editor_controls, spacing=20)
self.vim_indicator = VimIndicator(self.vim_box)
- self.vim_indicator.setSizePolicy(
+
+ 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
From e40101447905a05f01852bf82a4d4941b115f5f6 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Wed, 14 Jul 2021 03:14:19 +0100
Subject: [PATCH 23/44] owpythonscript: Name preferences box
---
Orange/widgets/data/owpythonscript.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 6a8363d815e..4932672aa07 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -683,7 +683,7 @@ def _(width):
# Controls
- self.editor_controls = gui.vBox(self.controlArea, box=True)
+ self.editor_controls = gui.vBox(self.controlArea, box='Preferences')
self.vim_box = gui.hBox(self.editor_controls, spacing=20)
self.vim_indicator = VimIndicator(self.vim_box)
From ce071f995f1e872bb7f8ba7687a00183dd443622 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Wed, 14 Jul 2021 23:47:38 +0100
Subject: [PATCH 24/44] owpythonscript: Implement darkMode
Relies on setting darkMode property in QApplication
---
Orange/widgets/data/owpythonscript.py | 112 ++++++------------
.../utils/pythoneditor/brackethighlighter.py | 6 +-
.../widgets/data/utils/pythoneditor/editor.py | 14 ++-
3 files changed, 48 insertions(+), 84 deletions(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 4932672aa07..53ae4e01f0b 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -1,7 +1,6 @@
import sys
import os
import code
-import keyword
import itertools
import tokenize
import unicodedata
@@ -11,20 +10,19 @@
import pygments.style
from pygments.token import Comment, Keyword, Number, String, Punctuation, Operator, Error, Name
-
+from qtconsole.pygments_highlighter import PygmentsHighlighter
from AnyQt.QtWidgets import (
QPlainTextEdit, QListView, QSizePolicy, QMenu, QSplitter, QLineEdit,
QAction, QToolButton, QFileDialog, QStyledItemDelegate,
QStyleOptionViewItem, QPlainTextDocumentLayout,
- QLabel, QWidget, QHBoxLayout)
+ QLabel, QWidget, QHBoxLayout, QApplication)
from AnyQt.QtGui import (
- QColor, QBrush, QPalette, QFont, QTextDocument,
- QSyntaxHighlighter, QTextCharFormat, QTextCursor, QKeySequence,
- QFontMetrics, QPainter
+ QColor, QBrush, QPalette, QFont, QTextDocument, QTextCharFormat,
+ QTextCursor, QKeySequence, QFontMetrics, QPainter
)
from AnyQt.QtCore import (
- Qt, QRegularExpression, QByteArray, QItemSelectionModel, QSize, QRectF
+ Qt, QByteArray, QItemSelectionModel, QSize, QRectF
)
from Orange.data import Table
@@ -84,6 +82,7 @@ def read_file_content(filename, limit=None):
"""
SYNTAX_HIGHLIGHTING_STYLES = {
'Light': {
+ Punctuation: "#000",
Error: '#f00',
Keyword: 'bold #008000',
@@ -105,7 +104,26 @@ def read_file_content(filename, limit=None):
Comment: 'italic #408080',
},
'Dark': {
- # TODO
+ Punctuation: "#fff",
+ Error: '#f00',
+
+ Keyword: 'bold #4caf50',
+
+ Name: '#e0e0e0',
+ Name.Function: '#1e88e5',
+ Name.Variable: '#42a5f5',
+ Name.Decorator: '#aa22ff',
+ Name.Builtin: '#43a047',
+ Name.Builtin.Pseudo: '#42a5f5',
+
+ String: '#ff7070',
+
+ Number: '#66bb6a',
+
+ Operator: 'bold #aa22ff',
+ Operator.Word: 'bold #4caf50',
+
+ Comment: 'italic #408080',
}
}
@@ -122,74 +140,6 @@ def make_pygments_style(scheme_name):
)
-PygmentsStyle = make_pygments_style('Light')
-
-
-class PythonSyntaxHighlighter(QSyntaxHighlighter):
- def __init__(self, parent=None):
-
- 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)
-
- self.keywords = list(keyword.kwlist)
-
- 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)]
-
- self.multilineStart = QRegularExpression(r"(''')|" + r'(""")')
- self.multilineEnd = QRegularExpression(r"(''')|" + r'(""")')
-
- super().__init__(parent)
-
- 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
- )
-
-
class FakeSignatureMixin:
def __init__(self, parent, highlighting_scheme, font):
super().__init__(parent)
@@ -624,10 +574,13 @@ def __init__(self):
)
self.defaultFontSize = defaultFontSize = 13
- self.editorBox = gui.vBox(self, box=True, spacing=4)
+ self.editorBox = gui.vBox(self, box="Editor", spacing=4)
self.splitCanvas.addWidget(self.editorBox)
- syntax_highlighting_scheme = SYNTAX_HIGHLIGHTING_STYLES['Light']
+ 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)
@@ -913,7 +866,8 @@ 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)
diff --git a/Orange/widgets/data/utils/pythoneditor/brackethighlighter.py b/Orange/widgets/data/utils/pythoneditor/brackethighlighter.py
index 00555695217..859e44d16b9 100644
--- a/Orange/widgets/data/utils/pythoneditor/brackethighlighter.py
+++ b/Orange/widgets/data/utils/pythoneditor/brackethighlighter.py
@@ -11,7 +11,7 @@
from AnyQt.QtCore import Qt
from AnyQt.QtGui import QTextCursor, QColor
-from AnyQt.QtWidgets import QTextEdit
+from AnyQt.QtWidgets import QTextEdit, QApplication
# Bracket highlighter.
# Calculates list of QTextEdit.ExtraSelection
@@ -108,6 +108,7 @@ 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
@@ -115,7 +116,8 @@ def _makeMatchSelection(self, block, columnIndex, matched):
fgColor = self.UNMATCHED_COLOR
selection.format.setForeground(fgColor)
- selection.format.setBackground(Qt.white) # repaint hack
+ # 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)
diff --git a/Orange/widgets/data/utils/pythoneditor/editor.py b/Orange/widgets/data/utils/pythoneditor/editor.py
index fc2265f5ba6..883f9350df8 100644
--- a/Orange/widgets/data/utils/pythoneditor/editor.py
+++ b/Orange/widgets/data/utils/pythoneditor/editor.py
@@ -87,7 +87,6 @@ def __init__(self, *args):
self._indenter = Indenter(self)
self._lineLengthEdge = None
self._lineLengthEdgeColor = QColor(255, 0, 0, 128)
- self._currentLineColor = QColor('#ffffff')
self._atomicModificationDepth = 0
self.drawIncorrectIndentation = True
@@ -104,8 +103,17 @@ def __init__(self, *args):
Hardcode same palette for not highlighted text
"""
palette = self.palette()
- palette.setColor(QPalette.Base, QColor('#ffffff'))
- palette.setColor(QPalette.Text, QColor('#000000'))
+ # 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()
From a3e45a2cbc0d78f0fe52f960ec00bc2739a8477c Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Thu, 15 Jul 2021 02:29:53 +0100
Subject: [PATCH 25/44] test_rectangular_selection: Add windows with_tabs
variant
---
.../utils/pythoneditor/tests/test_rectangular_selection.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_rectangular_selection.py b/Orange/widgets/data/utils/pythoneditor/tests/test_rectangular_selection.py
index 34217d0ed72..2e03031c578 100755
--- a/Orange/widgets/data/utils/pythoneditor/tests/test_rectangular_selection.py
+++ b/Orange/widgets/data/utils/pythoneditor/tests/test_rectangular_selection.py
@@ -109,9 +109,10 @@ def test_with_tabs(self):
QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier)
QTest.keyClick(self.qpart, Qt.Key_Delete)
- # 2 variants, Qt bahavior differs on different systems
+ # 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'))
+ 'abcdefh\n\tkl\n\t\t',
+ 'abcdefhhh\n\tkl\n\t\tyz'))
def test_delete(self):
self.qpart.show()
From 0d8c29ecc27a6337c56f44d7995f8d55d0012c4e Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Thu, 15 Jul 2021 14:24:15 +0100
Subject: [PATCH 26/44] [nomerge] replace quietunittest with normal
---
tox.ini | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tox.ini b/tox.ini
index ab2e9ec87ee..7ca3d06c5b3 100644
--- a/tox.ini
+++ b/tox.ini
@@ -45,8 +45,8 @@ commands_pre =
# freeze environment
pip freeze
commands =
- coverage run {toxinidir}/quietunittest.py Orange.tests Orange.widgets.tests Orange.canvas.tests
- coverage run {toxinidir}/quietunittest.py discover Orange.canvas.tests
+ coverage run -m unittest -v Orange.tests Orange.widgets.tests Orange.canvas.tests
+ coverage run -m unittest discover -v Orange.canvas.tests
coverage combine
coverage report
From 439f0300f756665d54958113a6d010ca59d99291 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Fri, 16 Jul 2021 01:15:57 +0100
Subject: [PATCH 27/44] owpythonscript: Terminate qutepart onDeleteWidget
---
Orange/widgets/data/owpythonscript.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 53ae4e01f0b..19f3243cf83 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -972,6 +972,10 @@ def migrate_settings(cls, settings, version):
for s in scripts] # type: List[_ScriptData]
settings["scriptLibrary"] = library
+ def onDeleteWidget(self):
+ self.text.terminate()
+ super().onDeleteWidget()
+
if __name__ == "__main__": # pragma: no cover
WidgetPreview(OWPythonScript).run()
From eb37aa6571f9059096ab3d13ec77fc6b5b26d5df Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Sun, 24 Jan 2021 04:03:06 +0000
Subject: [PATCH 28/44] rearrange comments
---
Orange/widgets/data/owpythonscript.py | 31 ++++++++++++++++++++++++++-
1 file changed, 30 insertions(+), 1 deletion(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 19f3243cf83..5f01eb47c1e 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -609,7 +609,6 @@ def __init__(self):
self.return_stmt = return_stmt
# Match indentation
-
textEditBox = QWidget(self.editorBox)
textEditBox.setLayout(QHBoxLayout())
char_4_width = QFontMetrics(eFont).horizontalAdvance('0000')
@@ -634,6 +633,33 @@ def _(width):
self.text.modificationChanged[bool].connect(self.onModificationChanged)
+ # Console
+
+ self.consoleBox = gui.vBox(self, 'Console')
+ self.splitCanvas.addWidget(self.consoleBox)
+
+ jupyter_widget = OrangeConsoleWidget(style_sheet=styles.default_light_style_sheet)
+ jupyter_widget.results_ready.connect(self.receive_outputs)
+
+ jupyter_widget.kernel_manager = kernel_manager
+ jupyter_widget.kernel_client = kernel_client
+
+ jupyter_widget._highlighter.set_style(PygmentsStyle)
+ 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.console = PythonConsole({}, self)
+ self.consoleBox.layout().addWidget(self.console)
+ self.console.document().setDefaultFont(QFont(defaultFont))
+ self.consoleBox.setAlignment(Qt.AlignBottom)
+ self.console.setTabStopWidth(4)
+ self.setAcceptDrops(True)
+
# Controls
self.editor_controls = gui.vBox(self.controlArea, box='Preferences')
@@ -752,6 +778,9 @@ def _(color, text):
self.splitCanvas.setSizes([2, 1])
self.controlArea.layout().addStretch(10)
+ # And finally,
+
+ self.splitCanvas.setSizes([2, 1])
self._restoreState()
self.settingsAboutToBePacked.connect(self._saveState)
From f40295be89bdc2b712aa697d1b32e015e976b68c Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Sun, 24 Jan 2021 04:32:49 +0000
Subject: [PATCH 29/44] owpythonscript: Refactor names
---
Orange/widgets/data/owpythonscript.py | 22 +++++------
.../widgets/data/tests/test_owpythonscript.py | 38 +++++++++----------
2 files changed, 30 insertions(+), 30 deletions(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 5f01eb47c1e..b7e689c582f 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -764,7 +764,7 @@ def _(color, text):
shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_R))
self.addAction(self.run_action)
- 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)
@@ -793,17 +793,17 @@ 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 _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):
@@ -849,7 +849,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, *_):
@@ -884,7 +884,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):
@@ -906,8 +906,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):
@@ -919,8 +919,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()
@@ -945,7 +945,7 @@ 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):
diff --git a/Orange/widgets/data/tests/test_owpythonscript.py b/Orange/widgets/data/tests/test_owpythonscript.py
index 2709d60ac67..2bb21e1d7ff 100644
--- a/Orange/widgets/data/tests/test_owpythonscript.py
+++ b/Orange/widgets/data/tests/test_owpythonscript.py
@@ -52,20 +52,20 @@ def test_outputs(self):
("Learner", self.learner),
("Classifier", self.model)):
lsignal = signal.lower()
- self.widget.text.setPlainText("out_{0} = in_{0}".format(lsignal))
+ self.widget.editor.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.editor.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.editor.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.editor.setPlainText("print(temp)")
self.widget.execute_button.click()
self.assertNotIn("NameError: name 'temp' is not defined",
self.widget.console.toPlainText())
@@ -82,13 +82,13 @@ def test_wrong_outputs(self):
("Classifier", self.model)):
lsignal = signal.lower()
self.send_signal(signal, data, 1)
- self.widget.text.setPlainText("out_{} = 42".format(lsignal))
+ self.widget.editor.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.editor.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())
@@ -132,26 +132,26 @@ def test_multiple_signals(self):
self.assertEqual(console_locals["in_datas"], [])
def test_store_new_script(self):
- self.widget.text.setPlainText("42")
+ self.widget.editor.setPlainText("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")
+ before = self.widget.editor.toPlainText()
+ self.widget.editor.setPlainText("42")
self.widget.restoreSaved()
- script = self.widget.text.toPlainText()
+ script = self.widget.editor.toPlainText()
self.assertEqual(before, script)
def test_store_current_script(self):
- self.widget.text.setPlainText("42")
+ self.widget.editor.setPlainText("42")
settings = self.widget.settingsHandler.pack_data(self.widget)
self.widget = self.create_widget(OWPythonScript)
- script = self.widget.text.toPlainText()
+ script = self.widget.editor.toPlainText()
self.assertNotEqual("42", script)
self.widget = self.create_widget(OWPythonScript, stored_settings=settings)
- script = self.widget.text.toPlainText()
+ script = self.widget.editor.toPlainText()
self.assertEqual("42", script)
def test_read_file_content(self):
@@ -166,18 +166,18 @@ 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.toPlainText()
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.toPlainText())
def test_script_insert_mime_file(self):
with named_file("test", suffix=".42") as fn:
- previous = self.widget.text.toPlainText()
+ previous = self.widget.editor.toPlainText()
mime = QMimeData()
url = QUrl.fromLocalFile(fn)
mime.setUrls([url])
From 55d13902beb9521a0b130e3756ea7cbe14c442a8 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Sun, 24 Jan 2021 04:40:16 +0000
Subject: [PATCH 30/44] owpythonscript: Implement qtconsole as 2nd process
---
Orange/widgets/data/owpythonscript.py | 203 +++++++---
.../widgets/data/tests/test_owpythonscript.py | 279 +++++++++----
Orange/widgets/data/utils/python_console.py | 213 ++++++++++
Orange/widgets/data/utils/python_kernel.py | 63 +++
Orange/widgets/data/utils/python_serialize.py | 380 ++++++++++++++++++
5 files changed, 1005 insertions(+), 133 deletions(-)
create mode 100644 Orange/widgets/data/utils/python_console.py
create mode 100644 Orange/widgets/data/utils/python_kernel.py
create mode 100644 Orange/widgets/data/utils/python_serialize.py
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index b7e689c582f..6667de21c3b 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -1,9 +1,15 @@
+import shutil
+import tempfile
+import uuid
+
import sys
import os
import code
import itertools
import tokenize
import unicodedata
+
+from jupyter_client import KernelManager
from unittest.mock import patch
from typing import Optional, List, TYPE_CHECKING
@@ -11,6 +17,11 @@
import pygments.style
from pygments.token import Comment, Keyword, Number, String, Punctuation, Operator, Error, Name
from qtconsole.pygments_highlighter import PygmentsHighlighter
+from qtconsole.pygments_highlighter import PygmentsHighlighter
+from qtconsole import styles
+from qtconsole.client import QtKernelClient
+from qtconsole.manager import QtKernelManager
+
from AnyQt.QtWidgets import (
QPlainTextEdit, QListView, QSizePolicy, QMenu, QSplitter, QLineEdit,
@@ -22,13 +33,16 @@
QTextCursor, QKeySequence, QFontMetrics, QPainter
)
from AnyQt.QtCore import (
- Qt, QByteArray, QItemSelectionModel, QSize, QRectF
+ Qt, QByteArray, QItemSelectionModel, QSize, QRectF, QTimer
)
+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
@@ -553,6 +567,9 @@ class Outputs:
vimModeEnabled = Setting(False)
+ class Warning(OWWidget.Warning):
+ illegal_var_type = Msg('{} should be of type {}, not {}.')
+
class Error(OWWidget.Error):
pass
@@ -619,32 +636,33 @@ def _(width):
textEditMargin = max(0, round(char_4_width - width))
return_stmt.setIndent(textEditMargin + width)
textEditBox.layout().setContentsMargins(
- textEditMargin, 0, 0, 0
+ int(textEditMargin), 0, 0, 0
)
- self.text = editor
+ 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.text.setTabStopWidth(4)
+ self.editor.setTabStopWidth(4)
- self.text.modificationChanged[bool].connect(self.onModificationChanged)
+ self.editor.modificationChanged[bool].connect(self.onModificationChanged)
# Console
self.consoleBox = gui.vBox(self, 'Console')
self.splitCanvas.addWidget(self.consoleBox)
- jupyter_widget = OrangeConsoleWidget(style_sheet=styles.default_light_style_sheet)
- jupyter_widget.results_ready.connect(self.receive_outputs)
+ # Qtconsole
- jupyter_widget.kernel_manager = kernel_manager
- jupyter_widget.kernel_client = kernel_client
+ jupyter_widget = OrangeConsoleWidget(
+ style_sheet=styles.default_light_style_sheet
+ )
+ jupyter_widget.results_ready.connect(self.receive_outputs)
- jupyter_widget._highlighter.set_style(PygmentsStyle)
+ jupyter_widget._highlighter.set_style(self.pygments_style_class)
jupyter_widget.font_family = defaultFont
jupyter_widget.font_size = defaultFontSize
jupyter_widget.reset_font()
@@ -652,18 +670,47 @@ def _(width):
self.console = jupyter_widget
self.consoleBox.layout().addWidget(self.console)
self.consoleBox.setAlignment(Qt.AlignBottom)
-
- self.console = PythonConsole({}, self)
- self.consoleBox.layout().addWidget(self.console)
- self.console.document().setDefaultFont(QFont(defaultFont))
- self.consoleBox.setAlignment(Qt.AlignBottom)
- self.console.setTabStopWidth(4)
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)
@@ -770,14 +817,6 @@ def _(color, text):
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.controlArea.layout().addStretch(10)
-
# And finally,
self.splitCanvas.setSizes([2, 1])
@@ -785,7 +824,7 @@ def _(color, text):
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]
@@ -801,6 +840,43 @@ def _restoreState(self):
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)
+
+ 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.kernel_manager = self.kernel_manager
+ self.console.kernel_client = self.kernel_client
+ self.console.set_kernel_id(ident)
+
+ 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.editor.toPlainText()
@@ -814,6 +890,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")
@@ -836,7 +961,9 @@ def handleNewSignals(self):
n: len(getattr(self, n)) for n in self.signal_names
})
- self.commit()
+ self.set_status('Injecting variables...')
+ vars = self.initial_locals_state()
+ self.console.set_vars(vars)
def selectedScriptIndex(self):
rows = self.libraryView.selectionModel().selectedRows()
@@ -953,29 +1080,15 @@ def initial_locals_state(self):
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 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)
- 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):
diff --git a/Orange/widgets/data/tests/test_owpythonscript.py b/Orange/widgets/data/tests/test_owpythonscript.py
index 2bb21e1d7ff..06cec8a8257 100644
--- a/Orange/widgets/data/tests/test_owpythonscript.py
+++ b/Orange/widgets/data/tests/test_owpythonscript.py
@@ -19,19 +19,61 @@
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 *
+from qtconsole.client import QtKernelClient
class TestOWPythonScript(WidgetTest):
def setUp(self):
+ super().setUp()
self.widget = self.create_widget(OWPythonScript)
self.iris = Table("iris")
self.learner = LogisticRegressionLearner()
self.model = self.learner(self.iris)
+ # self.widget.show()
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()
+ self.widget.onDeleteWidget()
+
+ 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_inputs(self):
"""Check widget's inputs"""
@@ -47,111 +89,168 @@ def test_inputs(self):
def test_outputs(self):
"""Check widget's outputs"""
- for signal, data in (
- ("Data", self.iris),
- ("Learner", self.learner),
- ("Classifier", self.model)):
+ # 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.widget.editor.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.editor.setPlainText("print(in_{})".format(lsignal))
- self.widget.execute_button.click()
+ 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.widget.editor.setPlainText("temp = 42\nprint(temp)")
- self.widget.execute_button.click()
- self.assertIn("42", self.widget.console.toPlainText())
- self.widget.editor.setPlainText("print(temp)")
- self.widget.execute_button.click()
+ 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.toPlainText())
+ self.widget.console._control.toPlainText())
def test_wrong_outputs(self):
"""
- Error is shown when output variables are filled with wrong variable
+ Warning 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)):
+ self.widget.orangeDataTablesEnabled = True
+ # 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.widget.editor.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.editor.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())
+ 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_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"], [])
+ 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_store_new_script(self):
- self.widget.editor.setPlainText("42")
+ self.widget.editor.text = "42"
self.widget.onAddScript()
script = self.widget.editor.toPlainText()
self.assertEqual("42", script)
def test_restore_from_library(self):
- before = self.widget.editor.toPlainText()
- self.widget.editor.setPlainText("42")
+ before = self.widget.editor.text
+ self.widget.editor.text = "42"
self.widget.restoreSaved()
- script = self.widget.editor.toPlainText()
+ script = self.widget.editor.text
self.assertEqual(before, script)
def test_store_current_script(self):
- self.widget.editor.setPlainText("42")
+ self.widget.editor.text = "42"
settings = self.widget.settingsHandler.pack_data(self.widget)
self.widget = self.create_widget(OWPythonScript)
- script = self.widget.editor.toPlainText()
+ script = self.widget.editor.text
self.assertNotEqual("42", script)
self.widget = self.create_widget(OWPythonScript, stored_settings=settings)
- script = self.widget.editor.toPlainText()
+ script = self.widget.editor.text
self.assertEqual("42", script)
def test_read_file_content(self):
@@ -166,29 +265,29 @@ def test_read_file_content(self):
self.assertIsNone(content)
def test_script_insert_mime_text(self):
- current = self.widget.editor.toPlainText()
+ current = self.widget.editor.text
insert = "test\n"
cursor = self.widget.editor.cursor()
cursor.setPos(0, 0)
mime = QMimeData()
mime.setText(insert)
self.widget.editor.insertFromMimeData(mime)
- self.assertEqual(insert + current, self.widget.editor.toPlainText())
+ 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.editor.toPlainText()
+ previous = self.widget.editor.text
mime = QMimeData()
url = QUrl.fromLocalFile(fn)
mime.setUrls([url])
- self.widget.text.insertFromMimeData(mime)
- text = self.widget.text.toPlainText().split("print('Hello world')")[0]
+ self.widget.editor.insertFromMimeData(mime)
+ text = self.widget.editor.text.split("print('Hello world')")[0]
self.assertTrue(
"'" + fn + "'",
text
)
- self.widget.text.undo()
- self.assertEqual(previous, self.widget.text.toPlainText())
+ 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:
@@ -237,16 +336,20 @@ def test_no_shared_namespaces(self):
widget1 = self.create_widget(OWPythonScript)
widget2 = self.create_widget(OWPythonScript)
- click1 = widget1.execute_button.click
- click2 = widget2.execute_button.click
+ self.send_signal(widget1.Inputs.data, self.iris, (1,), widget=widget1)
+ self.widget = widget1
+ self.wait_execute_script("x = 42")
- widget1.text.text = "x = 42"
- click1()
-
- widget2.text.text = "y = 2 * x"
- click2()
+ self.widget = widget2
+ self.wait_execute_script("y = 2 * x")
self.assertIn("NameError: name 'x' is not defined",
- widget2.console.toPlainText())
+ 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))
if __name__ == '__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..ca0ed97e7d2
--- /dev/null
+++ b/Orange/widgets/data/utils/python_console.py
@@ -0,0 +1,213 @@
+import codecs
+import logging
+
+import itertools
+import pickle
+import threading
+
+from AnyQt.QtCore import Qt, Signal
+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_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.inject_vars_comm = None
+ self.collect_vars_comm = None
+
+ self.style_sheet = style_sheet + \
+ '.run-prompt { color: #aa22ff; }'
+
+ # 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.
+ @self.becomes_ready.connect
+ def _():
+ self.becomes_ready.disconnect(_) # reset callback
+ self.init_client()
+ self.becomes_ready.connect(self.__on_ready)
+ self.__on_ready()
+
+ def __on_ready(self):
+ 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 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:
+ 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:
+ self.__is_ready = False
+ self.__queued_broadcast = (vars, )
+ return
+
+ self.__broadcasting = True
+
+ self.in_prompt = "Injecting variables..."
+ self._update_prompt(self.__prompt_num)
+
+ 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)
+
+ # 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..f150dae3637
--- /dev/null
+++ b/Orange/widgets/data/utils/python_kernel.py
@@ -0,0 +1,63 @@
+# Watch what you import in this file,
+# it may hang kernel on startup
+from collections import defaultdict
+
+from ipykernel.ipkernel import IPythonKernel
+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):
+
+ signals = ("data", "learner", "classifier", "object")
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.variables = defaultdict(list)
+ self.init_comms_kernel()
+ self.handle_new_vars({})
+
+ def handle_new_vars(self, vars):
+ default_vars = defaultdict(list)
+ default_vars.update(vars)
+
+ input_vars = {}
+
+ for signal in self.signals:
+ # remove old out_ vars
+ out_name = 'out_' + signal
+ if out_name in self.shell.user_ns:
+ del self.shell.user_ns[out_name]
+ self.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'] = []
+
+ self.shell.push(input_vars)
+ self.variables.update(input_vars)
+
+ async def execute_request(self, *args, **kwargs):
+ await super().execute_request(*args, **kwargs)
+ if not self.is_initialized():
+ return
+
+ vars = defaultdict(list)
+ for signal in self.signals:
+ key = signal + 's'
+ name = 'out_' + signal
+ if name in self.shell.user_ns:
+ var = self.shell.user_ns[name]
+ vars[key].append(var)
+
+ self.set_vars(vars)
+
+ return result
diff --git a/Orange/widgets/data/utils/python_serialize.py b/Orange/widgets/data/utils/python_serialize.py
new file mode 100644
index 00000000000..df7f0f0ccff
--- /dev/null
+++ b/Orange/widgets/data/utils/python_serialize.py
@@ -0,0 +1,380 @@
+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.__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):
+ self.socket.bind('ipc://' + self.__kernel_id)
+
+ 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.socket.connect('ipc://' + self.__kernel_id)
+ 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.__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):
+ i = msg['content']['data']['id']
+ self.set_kernel_id(i, kernel=True)
+
+ 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
From dec7a7c1c21d1c9f759075b11abd8b5fbbec4f3a Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Sun, 24 Jan 2021 18:39:50 +0000
Subject: [PATCH 31/44] owpythonscript: Remove PythonConsole
---
Orange/widgets/data/owpythonscript.py | 169 +-------------------------
1 file changed, 2 insertions(+), 167 deletions(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 6667de21c3b..98d7873035d 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -4,33 +4,29 @@
import sys
import os
-import code
-import itertools
import tokenize
import unicodedata
from jupyter_client import KernelManager
-from unittest.mock import patch
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.pygments_highlighter import PygmentsHighlighter
from qtconsole import styles
from qtconsole.client import QtKernelClient
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,
QLabel, QWidget, QHBoxLayout, QApplication)
from AnyQt.QtGui import (
QColor, QBrush, QPalette, QFont, QTextDocument, QTextCharFormat,
- QTextCursor, QKeySequence, QFontMetrics, QPainter
+ QKeySequence, QFontMetrics, QPainter
)
from AnyQt.QtCore import (
Qt, QByteArray, QItemSelectionModel, QSize, QRectF, QTimer
@@ -40,7 +36,6 @@
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
@@ -308,166 +303,6 @@ def minimumSizeHint(self):
return QSize(width, height)
-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
-
- 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)
-
- 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)
-
- 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 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()))
- return
-
-
class Script:
Modified = 1
MissingFromFilesystem = 2
From 0a1c85e4eafa065fc56b9ffa4fa879123c748338 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Sun, 31 Jan 2021 00:23:00 +0000
Subject: [PATCH 32/44] owpythonscript: Offer to run kernel in-process
---
Orange/widgets/data/owpythonscript.py | 25 ++++++-
Orange/widgets/data/utils/python_console.py | 43 +++++++++--
Orange/widgets/data/utils/python_kernel.py | 79 +++++++++++----------
3 files changed, 101 insertions(+), 46 deletions(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 98d7873035d..437c5186d25 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -16,6 +16,7 @@
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
@@ -401,6 +402,7 @@ class Outputs:
splitterState: Optional[bytes] = Setting(None)
vimModeEnabled = Setting(False)
+ useInProcessKernel = Setting(False)
class Warning(OWWidget.Warning):
illegal_var_type = Msg('{} should be of type {}, not {}.')
@@ -572,6 +574,14 @@ def _(color, text):
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 = []
@@ -684,9 +694,14 @@ def init_kernel(self):
ident = str(uuid.uuid4()).split('-')[-1]
cf = os.path.join(self._temp_connection_dir, 'kernel-%s.json' % ident)
- self.kernel_manager = QtKernelManager(
- connection_file=cf
- )
+ if self.useInProcessKernel:
+ self.kernel_manager = QtInProcessKernelManager(
+ connection_file=cf
+ )
+ else:
+ self.kernel_manager = QtKernelManager(
+ connection_file=cf
+ )
self.kernel_manager.start_kernel(
extra_arguments=[
@@ -703,9 +718,11 @@ def init_kernel(self):
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()
@@ -795,7 +812,9 @@ def handleNewSignals(self):
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)
diff --git a/Orange/widgets/data/utils/python_console.py b/Orange/widgets/data/utils/python_console.py
index ca0ed97e7d2..201070d2890 100644
--- a/Orange/widgets/data/utils/python_console.py
+++ b/Orange/widgets/data/utils/python_console.py
@@ -6,6 +6,7 @@
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
@@ -33,6 +34,7 @@ class OrangeConsoleWidget(OrangeZMQMixin, RichJupyterWidget):
def __init__(self, *args, style_sheet='', **kwargs):
super().__init__(*args, **kwargs)
+ self.__is_in_process = False
self.__is_ready = False
self.__queued_broadcast = None
@@ -43,19 +45,20 @@ def __init__(self, *args, style_sheet='', **kwargs):
self.__broadcasting = False
self.__threads = []
- self.inject_vars_comm = None
- self.collect_vars_comm = None
-
self.style_sheet = style_sheet + \
'.run-prompt { color: #aa22ff; }'
+ self.queue_init_client()
+
+ def queue_init_client(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.
@self.becomes_ready.connect
def _():
self.becomes_ready.disconnect(_) # reset callback
- self.init_client()
+ if not self.is_in_process():
+ self.init_client()
self.becomes_ready.connect(self.__on_ready)
self.__on_ready()
@@ -78,6 +81,24 @@ def __run_queued_payload(self):
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
+
+ try:
+ self.becomes_ready.disconnect(self.__on_ready)
+ except:
+ pass
+ self.queue_init_client()
+
+ def is_in_process(self):
+ return self.__is_in_process
+
def run_script(self, script):
"""
Inject the in vars, run the script,
@@ -127,7 +148,12 @@ def set_vars(self, vars):
self.in_prompt = "Injecting variables..."
self._update_prompt(self.__prompt_num)
- super().set_vars(vars)
+ 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')
@@ -186,6 +212,13 @@ def _handle_execute_reply(self, msg):
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)
diff --git a/Orange/widgets/data/utils/python_kernel.py b/Orange/widgets/data/utils/python_kernel.py
index f150dae3637..03349c30625 100644
--- a/Orange/widgets/data/utils/python_kernel.py
+++ b/Orange/widgets/data/utils/python_kernel.py
@@ -11,38 +11,13 @@
class OrangeIPythonKernel(OrangeZMQMixin, IPythonKernel):
- signals = ("data", "learner", "classifier", "object")
-
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- self.variables = defaultdict(list)
+ self.variables = {}
self.init_comms_kernel()
- self.handle_new_vars({})
def handle_new_vars(self, vars):
- default_vars = defaultdict(list)
- default_vars.update(vars)
-
- input_vars = {}
-
- for signal in self.signals:
- # remove old out_ vars
- out_name = 'out_' + signal
- if out_name in self.shell.user_ns:
- del self.shell.user_ns[out_name]
- self.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'] = []
-
- self.shell.push(input_vars)
+ input_vars = update_kernel_vars(self, vars, self.signals)
self.variables.update(input_vars)
async def execute_request(self, *args, **kwargs):
@@ -50,14 +25,42 @@ async def execute_request(self, *args, **kwargs):
if not self.is_initialized():
return
- vars = defaultdict(list)
- for signal in self.signals:
- key = signal + 's'
- name = 'out_' + signal
- if name in self.shell.user_ns:
- var = self.shell.user_ns[name]
- vars[key].append(var)
-
- self.set_vars(vars)
-
- return result
+ 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
From 4b6ea786d0cca13547b6318970e77830acea75ca Mon Sep 17 00:00:00 2001
From: Rafael
Date: Fri, 16 Jul 2021 17:05:53 +0100
Subject: [PATCH 33/44] python_serialize: TCP support on windows
---
Orange/widgets/data/utils/python_serialize.py | 29 ++++++++++++++++---
1 file changed, 25 insertions(+), 4 deletions(-)
diff --git a/Orange/widgets/data/utils/python_serialize.py b/Orange/widgets/data/utils/python_serialize.py
index df7f0f0ccff..0e927523d2c 100644
--- a/Orange/widgets/data/utils/python_serialize.py
+++ b/Orange/widgets/data/utils/python_serialize.py
@@ -1,3 +1,4 @@
+import sys
from _weakref import ref
import logging
@@ -136,6 +137,7 @@ class OrangeZMQMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+ self.is_ipc = 'win32' not in sys.platform
self.__kernel_id = None
self.__threads = []
@@ -202,7 +204,13 @@ def is_initialized(self):
return self.__kernel_id is not None
def init_socket_kernel(self):
- self.socket.bind('ipc://' + self.__kernel_id)
+ 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):
@@ -234,7 +242,6 @@ def assign_comm(comm, _):
)
def init_client(self):
- self.socket.connect('ipc://' + self.__kernel_id)
self.request_comm = self.kernel_client.comm_manager.new_comm(
'request_comm', {}
)
@@ -246,6 +253,11 @@ def init_client(self):
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
@@ -301,8 +313,17 @@ def __on_comm_request(self, msg):
self.__send_vars(payload)
def __on_comm_init(self, msg):
- i = msg['content']['data']['id']
- self.set_kernel_id(i, kernel=True)
+ 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)
From 7b813efc92d0f490170d702d7874fa02b29c526e Mon Sep 17 00:00:00 2001
From: Rafael
Date: Fri, 16 Jul 2021 17:40:41 +0100
Subject: [PATCH 34/44] owpythonscript: Add rubber
---
Orange/widgets/data/owpythonscript.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 437c5186d25..a03a87a6054 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -649,7 +649,7 @@ def _(color, text):
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)
self.run_action = QAction("Run script", self, triggered=self.commit,
From ccf8b6638dea694a98e74fc23d4019fb8f1e8551 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Fri, 16 Jul 2021 17:58:20 +0100
Subject: [PATCH 35/44] owpythonscript: Shutdown kernel onDelete Widget
---
Orange/widgets/data/owpythonscript.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index a03a87a6054..95d97bc3e00 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -969,7 +969,8 @@ def migrate_settings(cls, settings, version):
settings["scriptLibrary"] = library
def onDeleteWidget(self):
- self.text.terminate()
+ self.editor.terminate()
+ self.shutdown_kernel()
super().onDeleteWidget()
From b13770cd8979bed3ffb3c6afca757b183485f719 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Fri, 16 Jul 2021 18:03:56 +0100
Subject: [PATCH 36/44] owpythonscript: Increment settingsVersion, use
inprocess kernel
---
Orange/widgets/data/owpythonscript.py | 8 ++++++--
Orange/widgets/data/tests/test_owpythonscript.py | 6 ++++++
2 files changed, 12 insertions(+), 2 deletions(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index 95d97bc3e00..cf8bde6964c 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -391,7 +391,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,
@@ -962,11 +962,15 @@ def dragEnterEvent(self, event): # pylint: disable=no-self-use
@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()
diff --git a/Orange/widgets/data/tests/test_owpythonscript.py b/Orange/widgets/data/tests/test_owpythonscript.py
index 06cec8a8257..1702ddb3bf0 100644
--- a/Orange/widgets/data/tests/test_owpythonscript.py
+++ b/Orange/widgets/data/tests/test_owpythonscript.py
@@ -319,6 +319,12 @@ 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)],
From 8a5c48014d10bc8a5dfbb7c27af73ef1b8caabd7 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Wed, 21 Jul 2021 22:20:22 +0100
Subject: [PATCH 37/44] OWPythonScript: Add autocomplete icons
---
.../widgets/data/icons/pythonscript/add.svg | 1 +
.../widgets/data/icons/pythonscript/class.svg | 74 +++++++++++++++++++
.../data/icons/pythonscript/function.svg | 70 ++++++++++++++++++
.../data/icons/pythonscript/instance.svg | 70 ++++++++++++++++++
.../data/icons/pythonscript/keyword.svg | 70 ++++++++++++++++++
.../data/icons/pythonscript/module.svg | 70 ++++++++++++++++++
.../widgets/data/icons/pythonscript/more.svg | 1 +
.../widgets/data/icons/pythonscript/param.svg | 70 ++++++++++++++++++
.../widgets/data/icons/pythonscript/path.svg | 70 ++++++++++++++++++
.../data/icons/pythonscript/property.svg | 70 ++++++++++++++++++
.../data/icons/pythonscript/restore.svg | 57 ++++++++++++++
.../widgets/data/icons/pythonscript/save.svg | 1 +
.../data/icons/pythonscript/statement.svg | 69 +++++++++++++++++
13 files changed, 693 insertions(+)
create mode 100644 Orange/widgets/data/icons/pythonscript/add.svg
create mode 100644 Orange/widgets/data/icons/pythonscript/class.svg
create mode 100644 Orange/widgets/data/icons/pythonscript/function.svg
create mode 100644 Orange/widgets/data/icons/pythonscript/instance.svg
create mode 100644 Orange/widgets/data/icons/pythonscript/keyword.svg
create mode 100644 Orange/widgets/data/icons/pythonscript/module.svg
create mode 100644 Orange/widgets/data/icons/pythonscript/more.svg
create mode 100644 Orange/widgets/data/icons/pythonscript/param.svg
create mode 100644 Orange/widgets/data/icons/pythonscript/path.svg
create mode 100644 Orange/widgets/data/icons/pythonscript/property.svg
create mode 100644 Orange/widgets/data/icons/pythonscript/restore.svg
create mode 100644 Orange/widgets/data/icons/pythonscript/save.svg
create mode 100644 Orange/widgets/data/icons/pythonscript/statement.svg
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 @@
+
+
From d280e12d20fcaa2f6045c375f853c6f04800abc9 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Thu, 22 Jul 2021 03:26:25 +0100
Subject: [PATCH 38/44] python_console: Startup stability refactor
If flicking in-process on and off, before this change,
it could get stuck on 'Injecting Variables'
---
Orange/widgets/data/utils/python_console.py | 20 ++++++--------------
1 file changed, 6 insertions(+), 14 deletions(-)
diff --git a/Orange/widgets/data/utils/python_console.py b/Orange/widgets/data/utils/python_console.py
index 201070d2890..50f3b97ce51 100644
--- a/Orange/widgets/data/utils/python_console.py
+++ b/Orange/widgets/data/utils/python_console.py
@@ -35,6 +35,7 @@ class OrangeConsoleWidget(OrangeZMQMixin, RichJupyterWidget):
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
@@ -48,21 +49,16 @@ def __init__(self, *args, style_sheet='', **kwargs):
self.style_sheet = style_sheet + \
'.run-prompt { color: #aa22ff; }'
- self.queue_init_client()
+ self.becomes_ready.connect(self.__on_ready)
- def queue_init_client(self):
+ 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.
- @self.becomes_ready.connect
- def _():
- self.becomes_ready.disconnect(_) # reset callback
+ if self.__is_starting_up:
+ self.__is_starting_up = False
if not self.is_in_process():
self.init_client()
- self.becomes_ready.connect(self.__on_ready)
- self.__on_ready()
-
- def __on_ready(self):
self.__is_ready = True
self.__run_queued_broadcast()
self.__run_queued_payload()
@@ -90,11 +86,7 @@ def set_in_process(self, enabled):
self.__broadcasting = False
self.__prompt_num = 1
- try:
- self.becomes_ready.disconnect(self.__on_ready)
- except:
- pass
- self.queue_init_client()
+ self.__is_starting_up = True
def is_in_process(self):
return self.__is_in_process
From 623fb897a42e1c47b744e820b7006c7836aa8d9b Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Thu, 22 Jul 2021 19:42:43 +0100
Subject: [PATCH 39/44] python_console: Handle kernel is None (when in_process)
---
Orange/widgets/data/utils/python_console.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/Orange/widgets/data/utils/python_console.py b/Orange/widgets/data/utils/python_console.py
index 50f3b97ce51..bb62cde1364 100644
--- a/Orange/widgets/data/utils/python_console.py
+++ b/Orange/widgets/data/utils/python_console.py
@@ -100,7 +100,8 @@ def run_script(self, script):
self.__queued_execution = (script, )
return
- if self.__executing or self.__broadcasting:
+ 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:
@@ -130,7 +131,8 @@ def set_vars(self, vars):
self.__queued_broadcast = (vars, )
return
- if self.__executing or self.__broadcasting:
+ 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
From d9002af7355a01e19865dec13f3c2a22e0629d31 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Thu, 22 Jul 2021 19:45:16 +0100
Subject: [PATCH 40/44] python_kernel: Disable stdout/stderr patching on
in_process
Orange already does its own patching, and does not expose
file descriptors for the patched out streams. Even if this
functionality is added to orange and canvas-core, many test
runners (JetBrains) do not, and the only case handled in the
ipykernel library is for pytest.
---
Orange/widgets/data/owpythonscript.py | 3 ++-
Orange/widgets/data/utils/python_kernel.py | 20 ++++++++++++++++++++
2 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py
index cf8bde6964c..a712529197a 100644
--- a/Orange/widgets/data/owpythonscript.py
+++ b/Orange/widgets/data/owpythonscript.py
@@ -33,6 +33,7 @@
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
@@ -695,7 +696,7 @@ def init_kernel(self):
cf = os.path.join(self._temp_connection_dir, 'kernel-%s.json' % ident)
if self.useInProcessKernel:
- self.kernel_manager = QtInProcessKernelManager(
+ self.kernel_manager = OrangeInProcessKernelManager(
connection_file=cf
)
else:
diff --git a/Orange/widgets/data/utils/python_kernel.py b/Orange/widgets/data/utils/python_kernel.py
index 03349c30625..fc65d2b5bee 100644
--- a/Orange/widgets/data/utils/python_kernel.py
+++ b/Orange/widgets/data/utils/python_kernel.py
@@ -2,7 +2,12 @@
# 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
@@ -64,3 +69,18 @@ def collect_kernel_vars(kernel, signals):
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)
From 1095f6aacbb8bc06e62cfccf7a1bdc32f420b300 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Thu, 22 Jul 2021 19:48:55 +0100
Subject: [PATCH 41/44] test_owpythonscript: Test in a single instance, also
test in_proc
---
.../widgets/data/tests/test_owpythonscript.py | 39 ++++++++++++-------
1 file changed, 24 insertions(+), 15 deletions(-)
diff --git a/Orange/widgets/data/tests/test_owpythonscript.py b/Orange/widgets/data/tests/test_owpythonscript.py
index 1702ddb3bf0..bf23d383e92 100644
--- a/Orange/widgets/data/tests/test_owpythonscript.py
+++ b/Orange/widgets/data/tests/test_owpythonscript.py
@@ -8,6 +8,7 @@
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
from Orange.widgets.widget import OWWidget
# import tests for python editor
@@ -19,21 +20,21 @@
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 *
-from qtconsole.client import QtKernelClient
class TestOWPythonScript(WidgetTest):
- def setUp(self):
- super().setUp()
- self.widget = self.create_widget(OWPythonScript)
- self.iris = Table("iris")
- self.learner = LogisticRegressionLearner()
- self.model = self.learner(self.iris)
- # self.widget.show()
+ iris = Table("iris")
+ learner = LogisticRegressionLearner()
+ model = learner(iris)
+ default_settings = None
+ python_widget = None
- def tearDown(self):
- super().tearDown()
- self.widget.onDeleteWidget()
+ def setUp(self):
+ if type(self).python_widget is None:
+ type(self).python_widget = self.create_widget(
+ OWPythonScript, stored_settings=self.default_settings)
+ self.widget = self.python_widget
+ self.wait_execute_script('clear')
def wait_execute_script(self, script=None):
"""
@@ -237,6 +238,7 @@ def test_store_new_script(self):
self.assertEqual("42", script)
def test_restore_from_library(self):
+ self.widget.restoreSaved()
before = self.widget.editor.text
self.widget.editor.text = "42"
self.widget.restoreSaved()
@@ -246,12 +248,14 @@ def test_restore_from_library(self):
def test_store_current_script(self):
self.widget.editor.text = "42"
settings = self.widget.settingsHandler.pack_data(self.widget)
- self.widget = self.create_widget(OWPythonScript)
- script = self.widget.editor.text
+ 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.editor.text
+ 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:
@@ -358,5 +362,10 @@ def test_unreferencible(self):
self.assertEqual(self.get_output("Object"), ('a', 14))
+class TestInProcessOWPythonScript(TestOWPythonScript):
+ default_settings = {'useInProcessKernel': True}
+ python_widget = None
+
+
if __name__ == '__main__':
unittest.main()
From 866a9d57fe1851dc1a2a46bda5bb254d6b375ab6 Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Wed, 28 Jul 2021 01:17:24 +0100
Subject: [PATCH 42/44] test_owpythonscript: Correctly test inprocess
namespaces
---
.../widgets/data/tests/test_owpythonscript.py | 24 ++++++++++++++++++-
1 file changed, 23 insertions(+), 1 deletion(-)
diff --git a/Orange/widgets/data/tests/test_owpythonscript.py b/Orange/widgets/data/tests/test_owpythonscript.py
index bf23d383e92..2f3335b5d0e 100644
--- a/Orange/widgets/data/tests/test_owpythonscript.py
+++ b/Orange/widgets/data/tests/test_owpythonscript.py
@@ -364,7 +364,29 @@ def test_unreferencible(self):
class TestInProcessOWPythonScript(TestOWPythonScript):
default_settings = {'useInProcessKernel': True}
- python_widget = None
+
+ def setUp(self):
+ self.widget = self.create_widget(OWPythonScript, stored_settings=self.default_settings)
+
+ def test_no_shared_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=self.default_settings)
+ widget2 = self.create_widget(OWPythonScript, stored_settings=self.default_settings)
+
+ 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__':
From 855a00e70ac90dfa5e3c1263a9a7ed334b3c8b6c Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Wed, 28 Jul 2021 01:17:56 +0100
Subject: [PATCH 43/44] [nomerge] trace malloc
---
tox.ini | 2 ++
1 file changed, 2 insertions(+)
diff --git a/tox.ini b/tox.ini
index 7ca3d06c5b3..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
From 192b05ac72dc56095b19e5a4f0a92797cfe0541b Mon Sep 17 00:00:00 2001
From: Rafael Irgolic
Date: Wed, 28 Jul 2021 03:51:48 +0100
Subject: [PATCH 44/44] test_owpythonscript: Reorganize tests
---
.../widgets/data/tests/test_owpythonscript.py | 273 +++++++++---------
1 file changed, 137 insertions(+), 136 deletions(-)
diff --git a/Orange/widgets/data/tests/test_owpythonscript.py b/Orange/widgets/data/tests/test_owpythonscript.py
index 2f3335b5d0e..62db91344ca 100644
--- a/Orange/widgets/data/tests/test_owpythonscript.py
+++ b/Orange/widgets/data/tests/test_owpythonscript.py
@@ -26,15 +26,138 @@ class TestOWPythonScript(WidgetTest):
iris = Table("iris")
learner = LogisticRegressionLearner()
model = learner(iris)
- default_settings = None
- python_widget = None
def setUp(self):
- if type(self).python_widget is None:
- type(self).python_widget = self.create_widget(
- OWPythonScript, stored_settings=self.default_settings)
- self.widget = self.python_widget
- self.wait_execute_script('clear')
+ self.widget = self.create_widget(OWPythonScript)
+
+ def test_inputs(self):
+ """Check widget's inputs"""
+ for input_, data in (("Data", self.iris),
+ ("Learner", self.learner),
+ ("Classifier", self.model),
+ ("Object", "object")):
+ self.assertEqual(getattr(self.widget, input_.lower()), {})
+ self.send_signal(input_, data, 1)
+ self.assertEqual(getattr(self.widget, input_.lower()), {1: data})
+ self.send_signal(input_, None, 1)
+ self.assertEqual(getattr(self.widget, input_.lower()), {})
+
+ def test_owns_errors(self):
+ self.assertIsNot(self.widget.Error, OWWidget.Error)
+
+ def test_store_new_script(self):
+ self.widget.editor.text = "42"
+ self.widget.onAddScript()
+ script = self.widget.editor.toPlainText()
+ self.assertEqual("42", script)
+
+ def test_restore_from_library(self):
+ self.widget.restoreSaved()
+ 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.editor.text = "42"
+ settings = self.widget.settingsHandler.pack_data(self.widget)
+ widget = self.create_widget(OWPythonScript)
+ script = widget.editor.text
+ self.assertNotEqual("42", script)
+ 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:
+ # valid file opens
+ content = read_file_content(fn)
+ self.assertEqual("Content", content)
+ # invalid utf-8 file does not
+ with open(fn, "wb") as f:
+ f.write(b"\xc3\x28")
+ content = read_file_content(fn)
+ self.assertIsNone(content)
+
+ def test_script_insert_mime_text(self):
+ current = self.widget.editor.text
+ insert = "test\n"
+ cursor = self.widget.editor.cursor()
+ cursor.setPos(0, 0)
+ mime = QMimeData()
+ mime.setText(insert)
+ 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.editor.text
+ mime = QMimeData()
+ url = QUrl.fromLocalFile(fn)
+ mime.setUrls([url])
+ 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:
+ event = self._drag_enter_event(QUrl.fromLocalFile(fn))
+ self.widget.dragEnterEvent(event)
+ self.assertTrue(event.isAccepted())
+
+ def test_dragEnterEvent_rejects_binary(self):
+ with named_file("", suffix=".42") as fn:
+ with open(fn, "wb") as f:
+ f.write(b"\xc3\x28")
+ event = self._drag_enter_event(QUrl.fromLocalFile(fn))
+ self.widget.dragEnterEvent(event)
+ self.assertFalse(event.isAccepted())
+
+ def _drag_enter_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 QDragEnterEvent(
+ QPoint(0, 0), Qt.MoveAction, data,
+ Qt.NoButton, Qt.NoModifier)
+
+ def test_migrate(self):
+ w = self.create_widget(OWPythonScript, {
+ "libraryListSource": [Script("A", "1")],
+ "__version__": 0
+ })
+ 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):
"""
@@ -76,18 +199,6 @@ def is_ready_and_clear():
self.widget.console.results_ready.disconnect(results_ready_callback)
self.widget.console.execution_finished.disconnect(execution_finished_callback)
- def test_inputs(self):
- """Check widget's inputs"""
- for input_, data in (("Data", self.iris),
- ("Learner", self.learner),
- ("Classifier", self.model),
- ("Object", "object")):
- self.assertEqual(getattr(self.widget, input_.lower()), {})
- self.send_signal(input_, data, 1)
- self.assertEqual(getattr(self.widget, input_.lower()), {1: data})
- self.send_signal(input_, None, 1)
- self.assertEqual(getattr(self.widget, input_.lower()), {})
-
def test_outputs(self):
"""Check widget's outputs"""
# The type equation method for learners and classifiers probably isn't ideal,
@@ -126,7 +237,6 @@ 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)
"""
- self.widget.orangeDataTablesEnabled = True
# see comment in test_outputs()
for signal, data, assert_method in (
("Data", self.iris, self.assert_table_equal),
@@ -142,9 +252,6 @@ def test_wrong_outputs(self):
assert_method(self.get_output(signal), data)
self.assertFalse(self.widget.Warning.illegal_var_type.is_shown())
- def test_owns_errors(self):
- self.assertIsNot(self.widget.Error, OWWidget.Error)
-
def test_multiple_signals(self):
titanic = Table("titanic")
@@ -231,112 +338,7 @@ def test_multiple_signals(self):
self.assertIn("[]",
self.widget.console._control.toPlainText())
- def test_store_new_script(self):
- self.widget.editor.text = "42"
- self.widget.onAddScript()
- script = self.widget.editor.toPlainText()
- self.assertEqual("42", script)
-
- def test_restore_from_library(self):
- self.widget.restoreSaved()
- 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.editor.text = "42"
- settings = self.widget.settingsHandler.pack_data(self.widget)
- widget = self.create_widget(OWPythonScript)
- script = widget.editor.text
- self.assertNotEqual("42", script)
- 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:
- # valid file opens
- content = read_file_content(fn)
- self.assertEqual("Content", content)
- # invalid utf-8 file does not
- with open(fn, "wb") as f:
- f.write(b"\xc3\x28")
- content = read_file_content(fn)
- self.assertIsNone(content)
-
- def test_script_insert_mime_text(self):
- current = self.widget.editor.text
- insert = "test\n"
- cursor = self.widget.editor.cursor()
- cursor.setPos(0, 0)
- mime = QMimeData()
- mime.setText(insert)
- 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.editor.text
- mime = QMimeData()
- url = QUrl.fromLocalFile(fn)
- mime.setUrls([url])
- 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:
- event = self._drag_enter_event(QUrl.fromLocalFile(fn))
- self.widget.dragEnterEvent(event)
- self.assertTrue(event.isAccepted())
-
- def test_dragEnterEvent_rejects_binary(self):
- with named_file("", suffix=".42") as fn:
- with open(fn, "wb") as f:
- f.write(b"\xc3\x28")
- event = self._drag_enter_event(QUrl.fromLocalFile(fn))
- self.widget.dragEnterEvent(event)
- self.assertFalse(event.isAccepted())
-
- def _drag_enter_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 QDragEnterEvent(
- QPoint(0, 0), Qt.MoveAction, data,
- Qt.NoButton, Qt.NoModifier)
-
- def test_migrate(self):
- w = self.create_widget(OWPythonScript, {
- "libraryListSource": [Script("A", "1")],
- "__version__": 0
- })
- 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")
-
- def test_no_shared_namespaces(self):
+ 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
@@ -362,13 +364,12 @@ def test_unreferencible(self):
self.assertEqual(self.get_output("Object"), ('a', 14))
-class TestInProcessOWPythonScript(TestOWPythonScript):
- default_settings = {'useInProcessKernel': True}
-
+class TestInProcessKernel(TestKernel):
def setUp(self):
- self.widget = self.create_widget(OWPythonScript, stored_settings=self.default_settings)
+ self.widget = self.create_widget(OWPythonScript,
+ stored_settings={'useInProcessKernel': True})
- def test_no_shared_namespaces(self):
+ 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
@@ -377,8 +378,8 @@ def test_no_shared_namespaces(self):
shared namespaces. If you find a way to disable shared namespaces,
go for it my dude.
"""
- widget1 = self.create_widget(OWPythonScript, stored_settings=self.default_settings)
- widget2 = self.create_widget(OWPythonScript, stored_settings=self.default_settings)
+ 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