From 8b4b20b6498350edd34460cb68b89fb301cf130b Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Thu, 15 Dec 2022 20:10:03 +0100 Subject: [PATCH 1/3] combobox: Add TextEditCombo utility class --- Orange/widgets/utils/combobox.py | 122 +++++++++++++++++++- Orange/widgets/utils/tests/test_combobox.py | 71 +++++++++++- 2 files changed, 185 insertions(+), 8 deletions(-) diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py index 616f5e6b6c6..865c641d9d9 100644 --- a/Orange/widgets/utils/combobox.py +++ b/Orange/widgets/utils/combobox.py @@ -1,11 +1,17 @@ -from AnyQt.QtCore import Qt -from AnyQt.QtGui import QBrush, QColor, QPalette, QPen, QFont, QFontMetrics -from AnyQt.QtWidgets import QStylePainter, QStyleOptionComboBox, QStyle +from AnyQt.QtCore import Qt, Signal +from AnyQt.QtGui import ( + QBrush, QColor, QPalette, QPen, QFont, QFontMetrics, QFocusEvent +) +from AnyQt.QtWidgets import ( + QStylePainter, QStyleOptionComboBox, QStyle, QApplication, QLineEdit +) -from orangewidget.utils.combobox import ComboBoxSearch, ComboBox +from orangewidget.utils.combobox import ( + ComboBoxSearch, ComboBox, qcombobox_emit_activated +) __all__ = [ - "ComboBoxSearch", "ComboBox", "ItemStyledComboBox" + "ComboBoxSearch", "ComboBox", "ItemStyledComboBox", "TextEditCombo" ] @@ -71,3 +77,109 @@ def initStyleOption(self, option: 'QStyleOptionComboBox') -> None: if self.currentIndex() == -1: option.currentText = self.__placeholderText option.palette.setCurrentColorGroup(QPalette.Disabled) + + +class TextEditCombo(ComboBox): + #: This signal is emitted whenever the contents of the combo box are + #: changed and the widget loses focus *OR* via item activation (activated + #: signal) + editingFinished = Signal() + + def __init__(self, *args, **kwargs): + kwargs.setdefault("editable", True) + # `activated=...` kwarg needs to be connected after `__on_activated` + activated = kwargs.pop("activated", None) + self.__edited = False + super().__init__(*args, **kwargs) + self.activated.connect(self.__on_activated) + if activated is not None: + self.activated.connect(activated) + ledit = self.lineEdit() + if ledit is not None: + ledit.textEdited.connect(self.__markEdited) + + def setLineEdit(self, edit: QLineEdit) -> None: + super().setLineEdit(edit) + edit.textEdited.connect(self.__markEdited) + + def __markEdited(self): + self.__edited = True + + def __on_activated(self): + self.__edited = False # mark clean on any activation + self.editingFinished.emit() + + def focusOutEvent(self, event: QFocusEvent) -> None: + super().focusOutEvent(event) + popup = QApplication.activePopupWidget() + if self.isEditable() and self.__edited and \ + (event.reason() != Qt.PopupFocusReason or + not (popup is not None + and popup.parent() in (self, self.lineEdit()))): + def monitor(): + # monitor if editingFinished was emitted from + # __on_editingFinished to avoid double emit. + nonlocal emitted + emitted = True + emitted = False + self.editingFinished.connect(monitor) + self.__edited = False + self.__on_editingFinished() + self.editingFinished.disconnect(monitor) + + if not emitted: + self.editingFinished.emit() + + def __on_editingFinished(self): + le = self.lineEdit() + policy = self.insertPolicy() + text = le.text() + if not text: + return + index = self.findText(text, Qt.MatchFixedString) + if index != -1: + self.setCurrentIndex(index) + qcombobox_emit_activated(self, index) + return + if policy == ComboBox.NoInsert: + return + elif policy == ComboBox.InsertAtTop: + index = 0 + elif policy == ComboBox.InsertAtBottom: + index = self.count() + elif policy == ComboBox.InsertAfterCurrent: + index = self.currentIndex() + 1 + elif policy == ComboBox.InsertBeforeCurrent: + index = max(self.currentIndex(), 0) + elif policy == ComboBox.InsertAlphabetically: + for index in range(self.count()): + if self.itemText(index).lower() >= text.lower(): + break + elif policy == ComboBox.InsertAtCurrent: + self.setItemText(self.currentIndex(), text) + qcombobox_emit_activated(self, self.currentIndex()) + return + + if index > -1: + self.insertItem(index, text) + self.setCurrentIndex(index) + qcombobox_emit_activated(self, self.currentIndex()) + + def text(self): + # type: () -> str + """ + Return the current text. + """ + return self.itemText(self.currentIndex()) + + def setText(self, text): + # type: (str) -> None + """ + Set `text` as the current text (adding it to the model if necessary). + """ + idx = self.findData(text, Qt.EditRole, Qt.MatchExactly) + if idx != -1: + self.setCurrentIndex(idx) + else: + self.addItem(text) + self.setCurrentIndex(self.count() - 1) diff --git a/Orange/widgets/utils/tests/test_combobox.py b/Orange/widgets/utils/tests/test_combobox.py index 46d92210931..29a430f72d6 100644 --- a/Orange/widgets/utils/tests/test_combobox.py +++ b/Orange/widgets/utils/tests/test_combobox.py @@ -1,8 +1,12 @@ -from AnyQt.QtCore import Qt -from AnyQt.QtGui import QFont, QColor +from AnyQt.QtCore import Qt, QEvent +from AnyQt.QtGui import QFont, QColor, QFocusEvent +from AnyQt.QtWidgets import QApplication +from AnyQt.QtTest import QTest, QSignalSpy from orangewidget.tests.base import GuiTest -from Orange.widgets.utils.combobox import ItemStyledComboBox +from orangewidget.tests.utils import simulate +from orangewidget.utils.itemmodels import PyListModel +from Orange.widgets.utils.combobox import ItemStyledComboBox, TextEditCombo class TestItemStyledComboBox(GuiTest): @@ -19,3 +23,64 @@ def test_combobox(self): Qt.FontRole: QFont("Windings") }) cb.grab() + + +class TestTextEditCombo(GuiTest): + def test_texteditcombo(self): + cb = TextEditCombo() + model = PyListModel() + cb.setModel(model) + + def enter_text(text: str): + cb.lineEdit().selectAll() + spy_act = QSignalSpy(cb.activated[int]) + spy_edit = QSignalSpy(cb.editingFinished) + QTest.keyClick(cb.lineEdit(), Qt.Key_Delete) + QTest.keyClicks(cb.lineEdit(), text) + QApplication.sendEvent( + cb, QFocusEvent(QEvent.FocusOut, Qt.TabFocusReason) + ) + self.assertEqual(len(spy_edit), 1) + if cb.insertPolicy() != TextEditCombo.NoInsert: + self.assertEqual(list(spy_act), [[cb.currentIndex()]]) + + cb.setInsertPolicy(TextEditCombo.NoInsert) + enter_text("!!") + self.assertEqual(list(model), []) + cb.setInsertPolicy(TextEditCombo.InsertAtTop) + enter_text("BB") + enter_text("AA") + self.assertEqual(list(model), ["AA", "BB"]) + cb.setInsertPolicy(TextEditCombo.InsertAtBottom) + enter_text("CC") + self.assertEqual(list(model), ["AA", "BB", "CC"]) + cb.setInsertPolicy(TextEditCombo.InsertBeforeCurrent) + cb.setCurrentIndex(1) + enter_text("AB") + self.assertEqual(list(model), ["AA", "AB", "BB", "CC"]) + cb.setInsertPolicy(TextEditCombo.InsertAfterCurrent) + cb.setCurrentIndex(2) + enter_text("BC") + self.assertEqual(list(model), ["AA", "AB", "BB", "BC", "CC"]) + cb.setInsertPolicy(TextEditCombo.InsertAtCurrent) + cb.setCurrentIndex(2) + enter_text("BBA") + self.assertEqual(list(model), ["AA", "AB", "BBA", "BC", "CC"]) + cb.setInsertPolicy(TextEditCombo.InsertAlphabetically) + enter_text("BCA") + self.assertEqual(list(model), ["AA", "AB", "BBA", "BC", "BCA", "CC"]) + + def test_activate_editing_finished_emit_ordering(self): + def activated(): + sigs.append("activated") + + def finished(): + sigs.append("finished") + + sigs = [] + cb = TextEditCombo( + activated=activated, editingFinished=finished + ) + cb.insertItem(0, "AA") + simulate.combobox_activate_index(cb, 0) + self.assertEqual(sigs, ["finished", "activated"]) From 5357f43a3929e36919b4279c4b5156967fa4d300 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Thu, 15 Dec 2022 20:10:49 +0100 Subject: [PATCH 2/3] owfile: Fix saving recent urls --- Orange/widgets/data/owfile.py | 16 +++++++++++----- Orange/widgets/data/tests/test_owfile.py | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/Orange/widgets/data/owfile.py b/Orange/widgets/data/owfile.py index fb3ae2dda38..35fc2691bf5 100644 --- a/Orange/widgets/data/owfile.py +++ b/Orange/widgets/data/owfile.py @@ -28,6 +28,8 @@ open_filename_dialog, stored_recent_paths_prepend from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Output, Msg +from Orange.widgets.utils.combobox import TextEditCombo + # Backward compatibility: class RecentPath used to be defined in this module, # and it is used in saved (pickled) settings. It must be imported into the @@ -232,13 +234,12 @@ def package(w): rb_button = gui.appendRadioButton(vbox, "URL:", addToLayout=False) layout.addWidget(rb_button, 3, 0, Qt.AlignVCenter) - self.url_combo = url_combo = QComboBox() + self.url_combo = url_combo = TextEditCombo() url_model = NamedURLModel(self.sheet_names) url_model.wrap(self.recent_urls) url_combo.setLineEdit(LineEditSelectOnFocus()) url_combo.setModel(url_model) url_combo.setSizePolicy(Policy.Ignored, Policy.Fixed) - url_combo.setEditable(True) url_combo.setInsertPolicy(url_combo.InsertAtTop) url_edit = url_combo.lineEdit() margins = url_edit.textMargins() @@ -343,14 +344,19 @@ def select_reader(self, n): self.load_data() def _url_set(self): + index = self.url_combo.currentIndex() url = self.url_combo.currentText() - pos = self.recent_urls.index(url) url = url.strip() if not urlparse(url).scheme: url = 'http://' + url - self.url_combo.setItemText(pos, url) - self.recent_urls[pos] = url + self.url_combo.setItemText(index, url) + + if index != 0: + model = self.url_combo.model() + root = self.url_combo.rootModelIndex() + model.moveRow(root, index, root, 0) + assert self.url_combo.currentIndex() == 0 self.source = self.URL self.load_data() diff --git a/Orange/widgets/data/tests/test_owfile.py b/Orange/widgets/data/tests/test_owfile.py index 264723c7c69..1426c44d101 100644 --- a/Orange/widgets/data/tests/test_owfile.py +++ b/Orange/widgets/data/tests/test_owfile.py @@ -14,6 +14,7 @@ from AnyQt.QtCore import QMimeData, QPoint, Qt, QUrl, QPointF from AnyQt.QtGui import QDragEnterEvent, QDropEvent +from AnyQt.QtTest import QTest from AnyQt.QtWidgets import QComboBox import Orange @@ -713,6 +714,24 @@ def read(): self.assertTrue(self.widget.Warning.load_warning.is_shown()) self.assertIn(WARNING_MSG, str(self.widget.Warning.load_warning)) + def test_recent_url_serialization(self): + with patch.object(self.widget, "load_data", lambda: None): + self.widget.url_combo.insertItem(0, "https://example.com/test.tab") + self.widget.url_combo.insertItem(1, "https://example.com/test1.tab") + self.widget.source = OWFile.URL + s = self.widget.settingsHandler.pack_data(self.widget) + self.assertEqual(s["recent_urls"], + ["https://example.com/test.tab", + "https://example.com/test1.tab"]) + self.widget.url_combo.lineEdit().clear() + QTest.keyClicks(self.widget.url_combo, "https://example.com/test1.tab") + QTest.keyClick(self.widget.url_combo, Qt.Key_Enter) + # must move the entered url to first position + s = self.widget.settingsHandler.pack_data(self.widget) + self.assertEqual(s["recent_urls"], + ["https://example.com/test1.tab", + "https://example.com/test.tab"]) + class TestOWFileDropHandler(unittest.TestCase): def test_canDropUrl(self): From 663ceae8c317068bb166583db007768baf84ad77 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 16 Dec 2022 14:24:27 +0100 Subject: [PATCH 3/3] textimport: Use TextEditCombo from utils --- Orange/widgets/utils/textimport.py | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/Orange/widgets/utils/textimport.py b/Orange/widgets/utils/textimport.py index 6d3fdfbbcfb..6b97f790bfb 100644 --- a/Orange/widgets/utils/textimport.py +++ b/Orange/widgets/utils/textimport.py @@ -55,6 +55,7 @@ from Orange.widgets.utils import encodings from Orange.widgets.utils.overlay import OverlayWidget +from Orange.widgets.utils.combobox import TextEditCombo __all__ = ["ColumnType", "RowSpec", "CSVOptionsWidget", "CSVImportWidget"] @@ -233,27 +234,6 @@ def minimumSizeHint(self): return super(LineEdit, self).sizeHint() -class TextEditCombo(QComboBox): - def text(self): - # type: () -> str - """ - Return the current text. - """ - return self.itemText(self.currentIndex()) - - def setText(self, text): - # type: (str) -> None - """ - Set `text` as the current text (adding it to the model if necessary). - """ - idx = self.findData(text, Qt.EditRole, Qt.MatchExactly) - if idx != -1: - self.setCurrentIndex(idx) - else: - self.addItem(text) - self.setCurrentIndex(self.count() - 1) - - class CSVOptionsWidget(QWidget): """ A widget presenting common CSV options.