Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ENH] OWPythonScript: Better text editor #5208

Merged
merged 24 commits into from
Aug 18, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f7b8955
Port andreikop/Qutepart 4be1145e73964da1ba06f2570e3bf5d739603fb6
irgolic Aug 16, 2020
cc5a83c
qutepart: Adjust for Orange
irgolic Jan 24, 2021
fe7b36d
owpythonscript: Replace editor for ported QutePart
irgolic Jan 24, 2021
17492a0
owpythonscript: Remove namespaces
irgolic Jan 24, 2021
e5cc30d
owpythonscript: Remove infobox
irgolic Jan 24, 2021
9f26b66
owpythonscript: Add fake function signature
irgolic Jan 24, 2021
dc30f83
owpythonscript: Add vim mode option/indicator
irgolic Jan 24, 2021
1e22df3
owpythonscript: Detect encoding onAddScriptFromFile
irgolic Feb 7, 2020
c6e435a
owpythonscript: Remove 'file' keyword
irgolic Mar 14, 2020
20d1daf
requirements-gui: Require qtconsole
irgolic Jan 24, 2021
041e14a
owpythonscript: Remove obsolete dropEvent
irgolic Jan 24, 2021
7d3f896
test_owpythonscript: Include editor tests
irgolic Jan 25, 2021
6b4343b
owpythonscript: Add fake return statement
irgolic Feb 1, 2021
745b827
owpythonscript: Correctly set editor font
irgolic Feb 1, 2021
13c3384
owpythonscript: Run on Shift+Enter
irgolic Feb 1, 2021
49c373e
pylint
irgolic Feb 1, 2021
5082752
owpythonscript: Set DejaVu Sans Mono font on linux
irgolic Feb 2, 2021
1d26dc8
owpythonscript: Keep vim indicator size when hidden
irgolic Jul 14, 2021
a528166
owpythonscript: Name preferences box
irgolic Jul 14, 2021
1446d13
owpythonscript: Implement darkMode
irgolic Jul 14, 2021
e54cdd8
test_rectangular_selection: Add windows with_tabs variant
irgolic Jul 15, 2021
b4fff39
[nomerge] replace quietunittest with normal
irgolic Jul 15, 2021
70de44e
owpythonscript: Terminate qutepart onDeleteWidget
irgolic Jul 16, 2021
124b050
pylint
irgolic Aug 13, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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