diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py index 5d2c5bc0f38..5a2117d56f6 100644 --- a/Orange/widgets/data/owpythonscript.py +++ b/Orange/widgets/data/owpythonscript.py @@ -17,6 +17,7 @@ ) from AnyQt.QtCore import Qt, QRegExp, QByteArray, QItemSelectionModel +from Orange.canvas.gui.utils import OSX_NSURL_toLocalFile from Orange.data import Table from Orange.base import Learner, Model from Orange.widgets import widget, gui @@ -35,6 +36,19 @@ def text_format(foreground=Qt.black, weight=QFont.Normal): return fmt +def to_local_file(url): + return OSX_NSURL_toLocalFile(url) or url.toLocalFile() + + +def read_file_content(filename, limit=None): + try: + with open(filename, encoding="utf-8", errors='strict') as f: + text = f.read(limit) + return text + except (OSError, UnicodeDecodeError): + return None + + class PythonSyntaxHighlighter(QSyntaxHighlighter): def __init__(self, parent=None): @@ -138,6 +152,24 @@ def keyPressEvent(self, 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(to_local_file(url)) + if new: + # inserting text like this allows undo + cursor = QTextCursor(self.document()) + cursor.select(QTextCursor.Document) + cursor.insertText(new) + class PythonConsole(QPlainTextEdit, code.InteractiveConsole): def __init__(self, locals=None, parent=None): @@ -524,6 +556,8 @@ def __init__(self): if self.splitterState is not None: self.splitCanvas.restoreState(QByteArray(self.splitterState)) + self.setAcceptDrops(True) + self.splitCanvas.splitterMoved[int, int].connect(self.onSpliterMoved) self.controlArea.layout().addStretch(1) self.resize(800, 600) @@ -708,6 +742,20 @@ def commit(self): out_var = None getattr(self.Outputs, signal).send(out_var) + def dragEnterEvent(self, event): + urls = event.mimeData().urls() + if urls: + # try reading the file as text + c = read_file_content(to_local_file(urls[0]), limit=1000) + 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]) + if __name__ == "__main__": # pragma: no cover WidgetPreview(OWPythonScript).run() diff --git a/Orange/widgets/data/tests/test_owpythonscript.py b/Orange/widgets/data/tests/test_owpythonscript.py index 3abace1ebba..17b5d0c1b2c 100644 --- a/Orange/widgets/data/tests/test_owpythonscript.py +++ b/Orange/widgets/data/tests/test_owpythonscript.py @@ -1,8 +1,12 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring +from AnyQt.QtCore import QMimeData, QUrl, QPoint, Qt +from AnyQt.QtGui import QDragEnterEvent, QDropEvent + from Orange.data import Table from Orange.classification import LogisticRegressionLearner -from Orange.widgets.data.owpythonscript import OWPythonScript +from Orange.tests import named_file +from Orange.widgets.data.owpythonscript import OWPythonScript, read_file_content from Orange.widgets.tests.base import WidgetTest from Orange.widgets.widget import OWWidget @@ -134,3 +138,75 @@ def test_store_current_script(self): self.widget = self.create_widget(OWPythonScript, stored_settings=settings) script = self.widget.text.toPlainText() self.assertEqual("42", script) + + 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.text.toPlainText() + insert = "test\n" + cursor = self.widget.text.cursor() + cursor.setPos(0, 0) + mime = QMimeData() + mime.setText(insert) + self.widget.text.insertFromMimeData(mime) + self.assertEqual(insert + current, self.widget.text.toPlainText()) + + def test_script_insert_mime_file(self): + with named_file("test", suffix=".42") as fn: + previous = self.widget.text.toPlainText() + mime = QMimeData() + url = QUrl.fromLocalFile(fn) + mime.setUrls([url]) + self.widget.text.insertFromMimeData(mime) + self.assertEqual("test", self.widget.text.toPlainText()) + self.widget.text.undo() + self.assertEqual(previous, self.widget.text.toPlainText()) + + 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 + self.event_data = data = QMimeData() + data.setUrls([QUrl(url)]) + return QDragEnterEvent( + 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 + self.event_data = data = QMimeData() + data.setUrls([QUrl(url)]) + + return QDropEvent( + QPoint(0, 0), Qt.MoveAction, data, + Qt.NoButton, Qt.NoModifier, QDropEvent.Drop)