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 = '>' + 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 = '>' + 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* + # don't change indentation then + return prevIndent + + if not re.match(r'\s*<[^/][^>]*[^/]>[^<>]*$', prevLineText): + # decrease indent when we write ': + # 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*]*[^/]>[^<>]*$', 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 = [ + '', '', + + '', + ] + if item_type is not None: + parts.extend(['' + ]) + parts.extend([ + '', '
', + html.escape(item_completion).replace(' ', ' '), + '', + item_type, + '
', + ]) + + return ''.join(parts) + + def hide(self): + """Override Qt method.""" + self.completion_position = None + self.completion_list = None + self.clear() + self.textedit.setFocus() + tooltip = getattr(self.textedit, 'tooltip_widget', None) + if tooltip: + tooltip.hide() + + QListWidget.hide(self) + QToolTip.hideText() + + def keyPressEvent(self, event): + """Override Qt method to process keypress.""" + # pylint: disable=too-many-branches + text, key = event.text(), event.key() + alt = event.modifiers() & Qt.AltModifier + shift = event.modifiers() & Qt.ShiftModifier + ctrl = event.modifiers() & Qt.ControlModifier + altgr = event.modifiers() and (key == Qt.Key_AltGr) + # Needed to properly handle Neo2 and other keyboard layouts + # See spyder-ide/spyder#11293 + neo2_level4 = (key == 0) # AltGr (ISO_Level5_Shift) in Neo2 on Linux + modifier = shift or ctrl or alt or altgr or neo2_level4 + if key in (Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab): + # Check that what was selected can be selected, + # otherwise timing issues + item = self.currentItem() + if item is None: + item = self.item(0) + + if self.is_up_to_date(item=item): + self.item_selected(item=item) + else: + self.hide() + self.textedit.keyPressEvent(event) + elif key == Qt.Key_Escape: + self.hide() + elif key in (Qt.Key_Left, Qt.Key_Right) or text in ('.', ':'): + self.hide() + self.textedit.keyPressEvent(event) + elif key in (Qt.Key_Up, Qt.Key_Down, Qt.Key_PageUp, Qt.Key_PageDown, + Qt.Key_Home, Qt.Key_End) and not modifier: + if key == Qt.Key_Up and self.currentRow() == 0: + self.setCurrentRow(self.count() - 1) + elif key == Qt.Key_Down and self.currentRow() == self.count() - 1: + self.setCurrentRow(0) + else: + QListWidget.keyPressEvent(self, event) + elif len(text) > 0 or key == Qt.Key_Backspace: + self.textedit.keyPressEvent(event) + self.update_current() + elif modifier: + self.textedit.keyPressEvent(event) + else: + self.hide() + QListWidget.keyPressEvent(self, event) - def 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 = '>' - 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 = '>' - 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* - # don't change indentation then - return prevIndent - - if not re.match(r'\s*<[^/][^>]*[^/]>[^<>]*$', prevLineText): - # decrease indent when we write ': - # 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*]*[^/]>[^<>]*$', 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 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + 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 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + 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 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + 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 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + 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 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + 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 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + 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 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + 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 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + 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 @@ + + + + + + image/svg+xml + + + + + + + + + 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 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + 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