Skip to content

Commit

Permalink
Merge pull request #5208 from irgolic/pythonscript-editor
Browse files Browse the repository at this point in the history
[ENH] OWPythonScript: Better text editor
  • Loading branch information
PrimozGodec authored Aug 18, 2021
2 parents cb22d6b + 124b050 commit 4b5e43c
Show file tree
Hide file tree
Showing 27 changed files with 7,775 additions and 230 deletions.
528 changes: 352 additions & 176 deletions Orange/widgets/data/owpythonscript.py

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion Orange/widgets/data/owselectcolumns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
94 changes: 44 additions & 50 deletions Orange/widgets/data/tests/test_owpythonscript.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
# Test methods with long descriptive names can omit docstrings
# pylint: disable=missing-docstring
# pylint: disable=missing-docstring, unused-wildcard-import
# pylint: disable=wildcard-import, protected-access
import sys
import unittest

from AnyQt.QtCore import QMimeData, QUrl, QPoint, Qt
from AnyQt.QtGui import QDragEnterEvent, QDropEvent
from AnyQt.QtGui import QDragEnterEvent

from Orange.data import Table
from Orange.classification import LogisticRegressionLearner
from Orange.tests import named_file
from Orange.widgets.data.owpythonscript import OWPythonScript, \
read_file_content, Script, OWPythonScriptDropHandler
from Orange.widgets.tests.base import WidgetTest, DummySignalManager
from Orange.widgets.tests.base import WidgetTest
from Orange.widgets.widget import OWWidget

# import tests for python editor
from Orange.widgets.data.utils.pythoneditor.tests.test_api import *
from Orange.widgets.data.utils.pythoneditor.tests.test_bracket_highlighter import *
from Orange.widgets.data.utils.pythoneditor.tests.test_draw_whitespace import *
from Orange.widgets.data.utils.pythoneditor.tests.test_edit import *
from Orange.widgets.data.utils.pythoneditor.tests.test_indent import *
from Orange.widgets.data.utils.pythoneditor.tests.test_indenter.test_python import *
from Orange.widgets.data.utils.pythoneditor.tests.test_rectangular_selection import *
from Orange.widgets.data.utils.pythoneditor.tests.test_vim import *


class TestOWPythonScript(WidgetTest):
def setUp(self):
Expand Down Expand Up @@ -176,7 +187,11 @@ def test_script_insert_mime_file(self):
url = QUrl.fromLocalFile(fn)
mime.setUrls([url])
self.widget.text.insertFromMimeData(mime)
self.assertEqual("test", self.widget.text.toPlainText())
text = self.widget.text.toPlainText().split("print('Hello world')")[0]
self.assertTrue(
"'" + fn + "'",
text
)
self.widget.text.undo()
self.assertEqual(previous, self.widget.text.toPlainText())

Expand All @@ -203,52 +218,6 @@ def _drag_enter_event(self, url):
QPoint(0, 0), Qt.MoveAction, data,
Qt.NoButton, Qt.NoModifier)

def test_dropEvent_replaces_file(self):
with named_file("test", suffix=".42") as fn:
previous = self.widget.text.toPlainText()
event = self._drop_event(QUrl.fromLocalFile(fn))
self.widget.dropEvent(event)
self.assertEqual("test", self.widget.text.toPlainText())
self.widget.text.undo()
self.assertEqual(previous, self.widget.text.toPlainText())

def _drop_event(self, url):
# make sure data does not get garbage collected before it used
# pylint: disable=attribute-defined-outside-init
self.event_data = data = QMimeData()
data.setUrls([QUrl(url)])

return QDropEvent(
QPoint(0, 0), Qt.MoveAction, data,
Qt.NoButton, Qt.NoModifier, QDropEvent.Drop)

def test_shared_namespaces(self):
widget1 = self.create_widget(OWPythonScript)
widget2 = self.create_widget(OWPythonScript)
self.signal_manager = DummySignalManager()
widget3 = self.create_widget(OWPythonScript)

self.send_signal(widget1.Inputs.data, self.iris, 1, widget=widget1)
widget1.text.setPlainText("x = 42\n"
"out_data = in_data\n")
widget1.execute_button.click()
self.assertIs(
self.get_output(widget1.Outputs.data, widget=widget1),
self.iris)

widget2.text.setPlainText("out_object = 2 * x\n"
"out_data = in_data")
widget2.execute_button.click()
self.assertEqual(
self.get_output(widget1.Outputs.object, widget=widget2),
84)
self.assertIsNone(self.get_output(widget1.Outputs.data, widget=widget2))

sys.last_traceback = None
widget3.text.setPlainText("out_object = 2 * x")
widget3.execute_button.click()
self.assertIsNotNone(sys.last_traceback)

def test_migrate(self):
w = self.create_widget(OWPythonScript, {
"libraryListSource": [Script("A", "1")],
Expand All @@ -263,6 +232,27 @@ def test_restore(self):
})
self.assertEqual(w.libraryListSource[0].name, "A")

def test_no_shared_namespaces(self):
"""
Previously, Python Script widgets in the same schema shared a namespace.
I (irgolic) think this is just a way to encourage users in writing
messy workflows with race conditions, so I encourage them to share
between Python Script widgets with Object signals.
"""
widget1 = self.create_widget(OWPythonScript)
widget2 = self.create_widget(OWPythonScript)

click1 = widget1.execute_button.click
click2 = widget2.execute_button.click

widget1.text.text = "x = 42"
click1()

widget2.text.text = "y = 2 * x"
click2()
self.assertIn("NameError: name 'x' is not defined",
widget2.console.toPlainText())


class TestOWPythonScriptDropHandler(unittest.TestCase):
def test_canDropFile(self):
Expand All @@ -275,3 +265,7 @@ def test_parametersFromFile(self):
r = handler.parametersFromFile(__file__)
item = r["scriptLibrary"][0]
self.assertEqual(item["filename"], __file__)


if __name__ == '__main__':
unittest.main()
Empty file.
160 changes: 160 additions & 0 deletions Orange/widgets/data/utils/pythoneditor/brackethighlighter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""
Adapted from a code editor component created
for Enki editor as replacement for QScintilla.
Copyright (C) 2020 Andrei Kopats
Originally licensed under the terms of GNU Lesser General Public License
as published by the Free Software Foundation, version 2.1 of the license.
This is compatible with Orange3's GPL-3.0 license.
"""
import time

from AnyQt.QtCore import Qt
from AnyQt.QtGui import QTextCursor, QColor
from AnyQt.QtWidgets import QTextEdit, QApplication

# Bracket highlighter.
# Calculates list of QTextEdit.ExtraSelection


class _TimeoutException(UserWarning):
"""Operation timeout happened
"""


class BracketHighlighter:
"""Bracket highliter.
Calculates list of QTextEdit.ExtraSelection
Currently, this class might be just a set of functions.
Probably, it will contain instance specific selection colors later
"""
MATCHED_COLOR = QColor('#0b0')
UNMATCHED_COLOR = QColor('#a22')

_MAX_SEARCH_TIME_SEC = 0.02

_START_BRACKETS = '({['
_END_BRACKETS = ')}]'
_ALL_BRACKETS = _START_BRACKETS + _END_BRACKETS
_OPOSITE_BRACKET = dict(zip(_START_BRACKETS + _END_BRACKETS, _END_BRACKETS + _START_BRACKETS))

# instance variable. None or ((block, columnIndex), (block, columnIndex))
currentMatchedBrackets = None

def _iterateDocumentCharsForward(self, block, startColumnIndex):
"""Traverse document forward. Yield (block, columnIndex, char)
Raise _TimeoutException if time is over
"""
# Chars in the start line
endTime = time.time() + self._MAX_SEARCH_TIME_SEC
for columnIndex, char in list(enumerate(block.text()))[startColumnIndex:]:
yield block, columnIndex, char
block = block.next()

# Next lines
while block.isValid():
for columnIndex, char in enumerate(block.text()):
yield block, columnIndex, char

if time.time() > endTime:
raise _TimeoutException('Time is over')

block = block.next()

def _iterateDocumentCharsBackward(self, block, startColumnIndex):
"""Traverse document forward. Yield (block, columnIndex, char)
Raise _TimeoutException if time is over
"""
# Chars in the start line
endTime = time.time() + self._MAX_SEARCH_TIME_SEC
for columnIndex, char in reversed(list(enumerate(block.text()[:startColumnIndex]))):
yield block, columnIndex, char
block = block.previous()

# Next lines
while block.isValid():
for columnIndex, char in reversed(list(enumerate(block.text()))):
yield block, columnIndex, char

if time.time() > endTime:
raise _TimeoutException('Time is over')

block = block.previous()

def _findMatchingBracket(self, bracket, qpart, block, columnIndex):
"""Find matching bracket for the bracket.
Return (block, columnIndex) or (None, None)
Raise _TimeoutException, if time is over
"""
if bracket in self._START_BRACKETS:
charsGenerator = self._iterateDocumentCharsForward(block, columnIndex + 1)
else:
charsGenerator = self._iterateDocumentCharsBackward(block, columnIndex)

depth = 1
oposite = self._OPOSITE_BRACKET[bracket]
for b, c_index, char in charsGenerator:
if qpart.isCode(b, c_index):
if char == oposite:
depth -= 1
if depth == 0:
return b, c_index
elif char == bracket:
depth += 1
return None, None

def _makeMatchSelection(self, block, columnIndex, matched):
"""Make matched or unmatched QTextEdit.ExtraSelection
"""
selection = QTextEdit.ExtraSelection()
darkMode = QApplication.instance().property('darkMode')

if matched:
fgColor = self.MATCHED_COLOR
else:
fgColor = self.UNMATCHED_COLOR

selection.format.setForeground(fgColor)
# repaint hack
selection.format.setBackground(Qt.white if not darkMode else QColor('#111111'))
selection.cursor = QTextCursor(block)
selection.cursor.setPosition(block.position() + columnIndex)
selection.cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor)

return selection

def _highlightBracket(self, bracket, qpart, block, columnIndex):
"""Highlight bracket and matching bracket
Return tuple of QTextEdit.ExtraSelection's
"""
try:
matchedBlock, matchedColumnIndex = self._findMatchingBracket(bracket, qpart,
block, columnIndex)
except _TimeoutException: # not found, time is over
return[] # highlight nothing

if matchedBlock is not None:
self.currentMatchedBrackets = ((block, columnIndex), (matchedBlock, matchedColumnIndex))
return [self._makeMatchSelection(block, columnIndex, True),
self._makeMatchSelection(matchedBlock, matchedColumnIndex, True)]
else:
self.currentMatchedBrackets = None
return [self._makeMatchSelection(block, columnIndex, False)]

def extraSelections(self, qpart, block, columnIndex):
"""List of QTextEdit.ExtraSelection's, which highlighte brackets
"""
blockText = block.text()

if columnIndex < len(blockText) and \
blockText[columnIndex] in self._ALL_BRACKETS and \
qpart.isCode(block, columnIndex):
return self._highlightBracket(blockText[columnIndex], qpart, block, columnIndex)
elif columnIndex > 0 and \
blockText[columnIndex - 1] in self._ALL_BRACKETS and \
qpart.isCode(block, columnIndex - 1):
return self._highlightBracket(blockText[columnIndex - 1], qpart, block, columnIndex - 1)
else:
self.currentMatchedBrackets = None
return []
Loading

0 comments on commit 4b5e43c

Please sign in to comment.