From f7b92d424a6a65b66c95b5307ff683ce74a09a38 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 16 Apr 2018 16:45:12 +0200 Subject: [PATCH 01/17] ComboBoxSearch: Add a new combo box control with filter/search --- Orange/widgets/utils/combobox.py | 282 +++++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 Orange/widgets/utils/combobox.py diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py new file mode 100644 index 00000000000..44dbd577647 --- /dev/null +++ b/Orange/widgets/utils/combobox.py @@ -0,0 +1,282 @@ + +from typing import Optional + +from AnyQt.QtCore import ( + Qt, QEvent, QObject, QAbstractItemModel, QModelIndex, + QSortFilterProxyModel, QSize, QRect, QMargins, QCoreApplication +) + +from AnyQt.QtGui import QMouseEvent, QKeyEvent +from AnyQt.QtWidgets import ( + QWidget, QComboBox, QLineEdit, QAbstractItemView, QListView, + QStyleOptionComboBox, QStyle, QStylePainter, QApplication +) + + +class ComboBoxSearch(QComboBox): + """ + A drop down list combo box with filter/search. + + The popup list view is filtered by text entered in the filter field. + + Note + ---- + `popup`, `lineEdit` and `completer` from the base QComboBox class are + unused. Setting/modifying them will have no effect. + """ + # NOTE: Setting editable + QComboBox.NoInsert policy + ... did not achieve + # the same results. + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__searchline = QLineEdit(self, visible=False, frame=False) + self.__searchline.setAttribute(Qt.WA_MacShowFocusRect, False) + self.__searchline.setFocusProxy(self) + self.__popup = None # type: Optional[QAbstractItemModel] + self.__proxy = None # type: Optional[QSortFilterProxyModel] + + def showPopup(self): + # type: () -> None + """ + Reimplemented from QComboBox.showPopup + + Popup up a customized view and filter edit line. + + Note + ---- + The .popup(), .lineEdit(), .completer() of the base class are not used. + """ + if self.__popup is not None: + self.__popup.hide() + self.__popup.deleteLater() + self.__popup = self.__proxy = None + + if self.count() == 0: + return + + opt = QStyleOptionComboBox() + self.initStyleOption(opt) + popup = QListView( + uniformItemSizes=True, + horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOff, + verticalScrollBarPolicy=Qt.ScrollBarAsNeeded, + iconSize=self.iconSize(), + ) + popup.setFocusProxy(self.__searchline) + popup.setParent(self, Qt.Popup | Qt.FramelessWindowHint) + proxy = QSortFilterProxyModel( + popup, filterCaseSensitivity=Qt.CaseInsensitive + ) + proxy.setFilterKeyColumn(self.modelColumn()) + proxy.setSourceModel(self.model()) + popup.setModel(proxy) + root = proxy.mapFromSource(self.rootModelIndex()) + popup.setRootIndex(root) + + self.__popup = popup + self.__proxy = proxy + self.__searchline.setText("") + self.__searchline.setPlaceholderText("Filter...") + self.__searchline.setVisible(True) + # focus proxy is cleared in hidePopup + self.__searchline.setFocusProxy(self) + self.__searchline.textEdited.connect(proxy.setFilterFixedString) + + style = self.style() # type: QStyle + + popuprect_origin = style.subControlRect( + QStyle.CC_ComboBox, opt, QStyle.SC_ComboBoxListBoxPopup, self + ) # type: QRect + popuprect_origin = QRect( + self.mapToGlobal(popuprect_origin.topLeft()), + popuprect_origin.size() + ) + editrect = style.subControlRect( + QStyle.CC_ComboBox, opt, QStyle.SC_ComboBoxEditField, self + ) # type: QRect + self.__searchline.setGeometry(editrect) + screenrect = QApplication.desktop().screenGeometry(self) # type: QRect + + # get the height for the view + listrect = QRect() + for i in range(min(proxy.rowCount(root), self.maxVisibleItems())): + index = proxy.index(i, self.modelColumn(), root) + if index.isValid(): + listrect = listrect.united(popup.visualRect(index)) + if listrect.height() >= screenrect.height(): + break + window = popup.window() # type: QWidget + window.ensurePolished() + if window.layout() is not None: + window.layout().activate() + else: + QApplication.sendEvent(window, QEvent(QEvent.LayoutRequest)) + + margins = qwidget_margin_within(popup.viewport(), window) + height = (listrect.height() + 2 * popup.spacing() + + margins.top() + margins.bottom()) + + popup_size = (QSize(popuprect_origin.width(), height) + .expandedTo(window.minimumSize()) + .boundedTo(window.maximumSize()) + .boundedTo(screenrect.size())) + popuprect = QRect(popuprect_origin.bottomLeft(), popup_size) + + # if the popup extends bellow the screen and there is more room above + # the popup origin ... + if popuprect.bottom() > screenrect.bottom() \ + and popuprect_origin.center().y() > screenrect.center().y(): + # ...flip the rect about the popup_origin so it extends upwards + popuprect.moveBottom(popuprect_origin.topLeft().y()) + + # fixup horizontal position if it extends outside the screen + if popuprect.left() < screenrect.left(): + popuprect.moveLeft(screenrect.left()) + if popuprect.right() > screenrect.right(): + popuprect.moveRight(screenrect.right()) + + # bound by screen geometry + popuprect = popuprect.intersected(screenrect) + + popup.setGeometry(popuprect) + + current = proxy.mapFromSource( + self.model().index(self.currentIndex(), self.modelColumn(), + self.rootModelIndex())) + popup.setCurrentIndex(current) + popup.scrollTo(current, QAbstractItemView.EnsureVisible) + popup.show() + popup.setFocus(Qt.PopupFocusReason) + popup.installEventFilter(self) + popup.viewport().installEventFilter(self) + self.update() + + def hidePopup(self): + """Reimplemented""" + if self.__popup is not None: + self.__popup.deleteLater() + self.__popup = self.__proxy = None + # need to call base hidePopup even though the base showPopup was not + # called (update internal state wrt. 'pressed' arrow, ... + super().hidePopup() + self.__searchline.setFocusProxy(None) + self.__searchline.hide() + self.update() + + def initStyleOption(self, option): + # type: (QStyleOptionComboBox) -> None + super().initStyleOption(option) + option.editable = True + + def __updateGeometries(self): + opt = QStyleOptionComboBox() + self.initStyleOption(opt) + editarea = self.style().subControlRect( + QStyle.CC_ComboBox, opt, QStyle.SC_ComboBoxEditField, self) + self.__searchline.setGeometry(editarea) + + def resizeEvent(self, event): + """Reimplemented.""" + super().resizeEvent(event) + self.__updateGeometries() + + def paintEvent(self, event): + """Reimplemented.""" + opt = QStyleOptionComboBox() + self.initStyleOption(opt) + + painter = QStylePainter(self) + painter.drawComplexControl(QStyle.CC_ComboBox, opt) + if not self.__searchline.isVisibleTo(self): + opt.editable = False + painter.drawControl(QStyle.CE_ComboBoxLabel, opt) + + def eventFilter(self, obj, event): + # type: (QObject, QEvent) -> bool + """Reimplemented.""" + etype = event.type() + if etype == QEvent.FocusOut and self.__popup is not None: + self.hidePopup() + return True + if etype == QEvent.Hide and self.__popup is not None: + self.hidePopup() + return False + + if etype == QEvent.KeyPress or etype == QEvent.KeyRelease \ + and obj is self.__popup: + event = event # type: QKeyEvent + key, modifiers = event.key(), event.modifiers() + if key in (Qt.Key_Enter, Qt.Key_Return, Qt.Key_Select): + current = self.__popup.currentIndex() + if current.isValid(): + self.__activateProxyIndex(current) + elif key in (Qt.Key_Up, Qt.Key_Down, + Qt.Key_PageUp, Qt.Key_PageDown): + return False + elif key in (Qt.Key_End, Qt.Key_Home) \ + and not modifiers & Qt.ControlModifier: + return False + elif key in (Qt.Key_Tab, Qt.Key_Backtab): + pass + elif key == Qt.Key_Escape or \ + (key == Qt.Key_F4 and modifiers & Qt.AltModifier): + self.__popup.hide() + else: + # pass the input events to the filter edit line + QCoreApplication.sendEvent(self.__searchline, event) + return True + + if etype == QEvent.MouseButtonRelease and self.__popup is not None \ + and obj is self.__popup.viewport(): + event = event # type: QMouseEvent + index = self.__popup.indexAt(event.pos()) + if index.isValid(): + self.__activateProxyIndex(index) + + return super().eventFilter(obj, event) + + def __activateProxyIndex(self, index): + # type: (QModelIndex) -> None + # Set current and activate the source index corresponding to the proxy + # index in the popup's model. + if self.__popup is not None and index.isValid(): + proxy = self.__popup.model() + assert index.model() is proxy + index = proxy.mapToSource(index) + assert index.model() is self.model() + if index.isValid() and \ + index.flags() & (Qt.ItemIsEnabled | Qt.ItemIsSelectable): + self.hidePopup() + text = self.itemText(index.row()) + self.setCurrentIndex(index.row()) + self.activated[int].emit(index.row()) + self.activated[str].emit(text) + + +def qwidget_margin_within(widget, ancestor): + # type: (QWidget, QWidget) -> QMargins + """ + Return the 'margins' of widget within its 'ancestor' + + Ancestor must be within the widget's parent hierarchy and both widgets must + share the same top level window. + + Parameters + ---------- + widget : QWidget + ancestor : QWidget + + Returns + ------- + margins: QMargins + """ + assert ancestor.isAncestorOf(widget) + assert ancestor.window() is widget.window() + r1 = widget.geometry() + r2 = ancestor.geometry() + topleft = r1.topLeft() + bottomright = r1.bottomRight() + topleft = widget.mapTo(ancestor, topleft) + bottomright = widget.mapTo(ancestor, bottomright) + return QMargins(topleft.x(), topleft.y(), + r2.right() - bottomright.x(), + r2.bottom() - bottomright.y()) From 01af59ef778059a5515324bf8468fb0866296b17 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 20 Apr 2018 13:44:54 +0200 Subject: [PATCH 02/17] ComboBoxSearch: Add mouse release ignore timer --- Orange/widgets/utils/combobox.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py index 44dbd577647..5f1dbab0e60 100644 --- a/Orange/widgets/utils/combobox.py +++ b/Orange/widgets/utils/combobox.py @@ -2,8 +2,8 @@ from typing import Optional from AnyQt.QtCore import ( - Qt, QEvent, QObject, QAbstractItemModel, QModelIndex, - QSortFilterProxyModel, QSize, QRect, QMargins, QCoreApplication + Qt, QEvent, QObject, QAbstractItemModel, QSortFilterProxyModel, + QModelIndex, QSize, QRect, QMargins, QCoreApplication, QElapsedTimer ) from AnyQt.QtGui import QMouseEvent, QKeyEvent @@ -33,6 +33,7 @@ def __init__(self, *args, **kwargs): self.__searchline.setFocusProxy(self) self.__popup = None # type: Optional[QAbstractItemModel] self.__proxy = None # type: Optional[QSortFilterProxyModel] + self.__popupTimer = QElapsedTimer() def showPopup(self): # type: () -> None @@ -149,6 +150,7 @@ def showPopup(self): popup.installEventFilter(self) popup.viewport().installEventFilter(self) self.update() + self.__popupTimer.restart() def hidePopup(self): """Reimplemented""" @@ -226,7 +228,9 @@ def eventFilter(self, obj, event): return True if etype == QEvent.MouseButtonRelease and self.__popup is not None \ - and obj is self.__popup.viewport(): + and obj is self.__popup.viewport() \ + and self.__popupTimer.hasExpired( + QApplication.doubleClickInterval()): event = event # type: QMouseEvent index = self.__popup.indexAt(event.pos()) if index.isValid(): From 292c73d774bd0684ad013a86537e5a87ef994ad6 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 20 Apr 2018 16:15:39 +0200 Subject: [PATCH 03/17] ComboBoxSearch: Draw separators --- Orange/widgets/utils/combobox.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py index 5f1dbab0e60..b551d59ad23 100644 --- a/Orange/widgets/utils/combobox.py +++ b/Orange/widgets/utils/combobox.py @@ -6,13 +6,26 @@ QModelIndex, QSize, QRect, QMargins, QCoreApplication, QElapsedTimer ) -from AnyQt.QtGui import QMouseEvent, QKeyEvent +from AnyQt.QtGui import QMouseEvent, QKeyEvent, QPainter, QPalette, QPen from AnyQt.QtWidgets import ( QWidget, QComboBox, QLineEdit, QAbstractItemView, QListView, - QStyleOptionComboBox, QStyle, QStylePainter, QApplication + QStyleOptionComboBox, QStyleOptionViewItem, QStyle, QStylePainter, + QStyledItemDelegate, QApplication ) +class _ComboBoxListDelegate(QStyledItemDelegate): + def paint(self, painter, option, index): + # type: (QPainter, QStyleOptionViewItem, QModelIndex) -> None + super().paint(painter, option, index) + if index.data(Qt.AccessibleDescriptionRole) == "separator": + palette = option.palette # type: QPalette + painter.setPen(QPen(palette.dark(), 1.0)) + rect = option.rect # type: QRect + y = rect.center().y() + painter.drawLine(rect.left(), y, rect.left() + rect.width(), y) + + class ComboBoxSearch(QComboBox): """ A drop down list combo box with filter/search. @@ -64,6 +77,7 @@ def showPopup(self): ) popup.setFocusProxy(self.__searchline) popup.setParent(self, Qt.Popup | Qt.FramelessWindowHint) + popup.setItemDelegate(_ComboBoxListDelegate(popup)) proxy = QSortFilterProxyModel( popup, filterCaseSensitivity=Qt.CaseInsensitive ) From 676b81dee83ecbcb0fd7f487052d5a2caba9a809 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 20 Apr 2018 17:20:09 +0200 Subject: [PATCH 04/17] owhierarchicalclustering: Use ComboSearchBox for 'Annotations' --- .../unsupervised/owhierarchicalclustering.py | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/Orange/widgets/unsupervised/owhierarchicalclustering.py b/Orange/widgets/unsupervised/owhierarchicalclustering.py index 7aac998f507..df4c2f57b27 100644 --- a/Orange/widgets/unsupervised/owhierarchicalclustering.py +++ b/Orange/widgets/unsupervised/owhierarchicalclustering.py @@ -11,8 +11,7 @@ from AnyQt.QtWidgets import ( QGraphicsWidget, QGraphicsObject, QGraphicsLinearLayout, QGraphicsPathItem, QGraphicsScene, QGraphicsView, QGridLayout, QFormLayout, QSizePolicy, - QGraphicsSimpleTextItem, - QGraphicsLayoutItem, QAction, + QGraphicsSimpleTextItem, QGraphicsLayoutItem, QAction, QComboBox ) from AnyQt.QtGui import ( QTransform, QPainterPath, QPainterPathStroker, QColor, QBrush, QPen, @@ -32,7 +31,7 @@ leaves, prune, top_clusters from Orange.widgets import widget, gui, settings -from Orange.widgets.utils import colorpalette, itemmodels +from Orange.widgets.utils import colorpalette, itemmodels, combobox from Orange.widgets.utils.annotated_data import (create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME) from Orange.widgets.widget import Input, Output, Msg @@ -825,9 +824,18 @@ def __init__(self): model = itemmodels.VariableListModel() model[:] = self.basic_annotations - self.label_cb = gui.comboBox( - self.controlArea, self, "annotation", box="Annotation", - model=model, callback=self._update_labels, contentsLength=12) + + box = gui.widgetBox(self.controlArea, "Annotations") + self.label_cb = combobox.ComboBoxSearch( + minimumContentsLength=14, + sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon + ) + box.layout().addWidget(self.label_cb) + self.label_cb.activated[int].connect( + lambda idx: setattr(self, "annotation", model[idx]) + ) + self.label_cb.activated.connect(self._update_labels) + self.label_cb.setModel(model) box = gui.radioButtons( self.controlArea, self, "pruning", box="Pruning", @@ -1052,6 +1060,7 @@ def _set_items(self, items, axis=1): else: self.annotation = "Enumeration" self.openContext(items.domain) + self.label_cb.setCurrentIndex(model.indexOf(self.annotation)) else: name_option = bool( items is not None and ( @@ -1711,7 +1720,7 @@ def clusters_at_height(root, height): return cluster_list -def main(argv=None): +def main(argv=None): # pragma: no cover from AnyQt.QtWidgets import QApplication import sip import Orange.distance as distance @@ -1737,12 +1746,13 @@ def main(argv=None): rval = app.exec_() w.set_distances(None) w.handleNewSignals() - + w.saveSettings() w.onDeleteWidget() sip.delete(w) del w app.processEvents() return rval -if __name__ == "__main__": + +if __name__ == "__main__": # pragma: no cover sys.exit(main()) From e1e38aee6c3f8cc60018e28020d9538fcc320134 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 23 Apr 2018 10:16:18 +0200 Subject: [PATCH 05/17] ComboBoxSearch: Improve focus tracking/proxying --- Orange/widgets/utils/combobox.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py index b551d59ad23..cb29c4c605c 100644 --- a/Orange/widgets/utils/combobox.py +++ b/Orange/widgets/utils/combobox.py @@ -60,9 +60,10 @@ def showPopup(self): The .popup(), .lineEdit(), .completer() of the base class are not used. """ if self.__popup is not None: - self.__popup.hide() - self.__popup.deleteLater() + popup = self.__popup self.__popup = self.__proxy = None + popup.hide() + popup.deleteLater() if self.count() == 0: return @@ -92,8 +93,6 @@ def showPopup(self): self.__searchline.setText("") self.__searchline.setPlaceholderText("Filter...") self.__searchline.setVisible(True) - # focus proxy is cleared in hidePopup - self.__searchline.setFocusProxy(self) self.__searchline.textEdited.connect(proxy.setFilterFixedString) style = self.style() # type: QStyle @@ -169,12 +168,15 @@ def showPopup(self): def hidePopup(self): """Reimplemented""" if self.__popup is not None: - self.__popup.deleteLater() + popup = self.__popup self.__popup = self.__proxy = None + popup.setFocusProxy(None) + popup.hide() + popup.deleteLater() + # need to call base hidePopup even though the base showPopup was not # called (update internal state wrt. 'pressed' arrow, ... super().hidePopup() - self.__searchline.setFocusProxy(None) self.__searchline.hide() self.update() From 4640e15acf7d31c1b5c07b433f67010e26309638 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 23 Apr 2018 10:52:45 +0200 Subject: [PATCH 06/17] ComboBoxSearch: Implement popup list mouse tracking --- Orange/widgets/utils/combobox.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py index cb29c4c605c..8c5ee9211db 100644 --- a/Orange/widgets/utils/combobox.py +++ b/Orange/widgets/utils/combobox.py @@ -1,11 +1,10 @@ - +# pylint: disable=unused-import from typing import Optional from AnyQt.QtCore import ( Qt, QEvent, QObject, QAbstractItemModel, QSortFilterProxyModel, QModelIndex, QSize, QRect, QMargins, QCoreApplication, QElapsedTimer ) - from AnyQt.QtGui import QMouseEvent, QKeyEvent, QPainter, QPalette, QPen from AnyQt.QtWidgets import ( QWidget, QComboBox, QLineEdit, QAbstractItemView, QListView, @@ -162,6 +161,7 @@ def showPopup(self): popup.setFocus(Qt.PopupFocusReason) popup.installEventFilter(self) popup.viewport().installEventFilter(self) + popup.viewport().setMouseTracking(True) self.update() self.__popupTimer.restart() @@ -201,14 +201,13 @@ def paintEvent(self, event): """Reimplemented.""" opt = QStyleOptionComboBox() self.initStyleOption(opt) - painter = QStylePainter(self) painter.drawComplexControl(QStyle.CC_ComboBox, opt) if not self.__searchline.isVisibleTo(self): opt.editable = False painter.drawControl(QStyle.CE_ComboBoxLabel, opt) - def eventFilter(self, obj, event): + def eventFilter(self, obj, event): # pylint: disable=too-many-branches # type: (QObject, QEvent) -> bool """Reimplemented.""" etype = event.type() @@ -252,6 +251,18 @@ def eventFilter(self, obj, event): if index.isValid(): self.__activateProxyIndex(index) + if etype == QEvent.MouseMove and self.__popup is not None \ + and obj is self.__popup.viewport(): + event = event # type: QMouseEvent + opt = QStyleOptionComboBox() + self.initStyleOption(opt) + style = self.style() # type: QStyle + if style.styleHint(QStyle.SH_ComboBox_ListMouseTracking, opt, self): + index = self.__popup.indexAt(event.pos()) + if index.isValid() and \ + index.flags() & (Qt.ItemIsEnabled | Qt.ItemIsSelectable): + self.__popup.setCurrentIndex(index) + return super().eventFilter(obj, event) def __activateProxyIndex(self, index): From 335758b5ff5e8daca9df0594361c0a3eed73fb82 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 23 Apr 2018 13:43:45 +0200 Subject: [PATCH 07/17] ComboBoxSearch: Use available desktop geometry, not the full screen --- Orange/widgets/utils/combobox.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py index 8c5ee9211db..dd4ad741e3e 100644 --- a/Orange/widgets/utils/combobox.py +++ b/Orange/widgets/utils/combobox.py @@ -107,7 +107,8 @@ def showPopup(self): QStyle.CC_ComboBox, opt, QStyle.SC_ComboBoxEditField, self ) # type: QRect self.__searchline.setGeometry(editrect) - screenrect = QApplication.desktop().screenGeometry(self) # type: QRect + desktop = QApplication.desktop() + screenrect = desktop.availableGeometry(self) # type: QRect # get the height for the view listrect = QRect() From f84327084a36323d72162b8d4b09f82cbaa3b225 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 23 Apr 2018 14:39:09 +0200 Subject: [PATCH 08/17] ComboBoxSearch: Extract `dropdown_popup_geometry` --- Orange/widgets/utils/combobox.py | 59 +++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py index dd4ad741e3e..f3f1470d5e7 100644 --- a/Orange/widgets/utils/combobox.py +++ b/Orange/widgets/utils/combobox.py @@ -3,7 +3,7 @@ from AnyQt.QtCore import ( Qt, QEvent, QObject, QAbstractItemModel, QSortFilterProxyModel, - QModelIndex, QSize, QRect, QMargins, QCoreApplication, QElapsedTimer + QModelIndex, QSize, QRect, QPoint, QMargins, QCoreApplication, QElapsedTimer ) from AnyQt.QtGui import QMouseEvent, QKeyEvent, QPainter, QPalette, QPen from AnyQt.QtWidgets import ( @@ -135,22 +135,8 @@ def showPopup(self): .boundedTo(screenrect.size())) popuprect = QRect(popuprect_origin.bottomLeft(), popup_size) - # if the popup extends bellow the screen and there is more room above - # the popup origin ... - if popuprect.bottom() > screenrect.bottom() \ - and popuprect_origin.center().y() > screenrect.center().y(): - # ...flip the rect about the popup_origin so it extends upwards - popuprect.moveBottom(popuprect_origin.topLeft().y()) - - # fixup horizontal position if it extends outside the screen - if popuprect.left() < screenrect.left(): - popuprect.moveLeft(screenrect.left()) - if popuprect.right() > screenrect.right(): - popuprect.moveRight(screenrect.right()) - - # bound by screen geometry - popuprect = popuprect.intersected(screenrect) - + popuprect = dropdown_popup_geometry( + popuprect, popuprect_origin, screenrect) popup.setGeometry(popuprect) current = proxy.mapFromSource( @@ -312,3 +298,42 @@ def qwidget_margin_within(widget, ancestor): return QMargins(topleft.x(), topleft.y(), r2.right() - bottomright.x(), r2.bottom() - bottomright.y()) + + +def dropdown_popup_geometry(geometry, origin, screen): + # type: (QRect, QRect, QRect) -> QRect + """ + Move/constrain the geometry for a drop down popup. + + Parameters + ---------- + geometry : QRect + The base popup geometry if not constrained. + origin : QRect + The origin rect from which the popup extends. + screen : QRect + The available screen geometry into which the popup must fit. + + Returns + ------- + geometry: QRect + Constrained drop down list geometry to fit into screen + """ + # if the popup geometry extends bellow the screen and there is more room + # above the popup origin ... + geometry = QRect(geometry) + geometry.moveTopLeft(origin.bottomLeft() + QPoint(0, 1)) + + if geometry.bottom() > screen.bottom() \ + and origin.center().y() > screen.center().y(): + # ...flip the rect about the origin so it extends upwards + geometry.moveBottom(origin.top() - 1) + + # fixup horizontal position if it extends outside the screen + if geometry.left() < screen.left(): + geometry.moveLeft(screen.left()) + if geometry.right() > screen.right(): + geometry.moveRight(screen.right()) + + # bounded by screen geometry + return geometry.intersected(screen) From 09619cd7171db504a6b70dee56848a15cfab93c8 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 23 Apr 2018 15:14:16 +0200 Subject: [PATCH 09/17] ComboBoxSearch: Set focus policy --- Orange/widgets/utils/combobox.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py index f3f1470d5e7..ddeed527dc6 100644 --- a/Orange/widgets/utils/combobox.py +++ b/Orange/widgets/utils/combobox.py @@ -46,6 +46,7 @@ def __init__(self, *args, **kwargs): self.__popup = None # type: Optional[QAbstractItemModel] self.__proxy = None # type: Optional[QSortFilterProxyModel] self.__popupTimer = QElapsedTimer() + self.setFocusPolicy(Qt.ClickFocus | Qt.TabFocus) def showPopup(self): # type: () -> None From 9d8e511f362d6a3fb48a834882f160f8cced7a58 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 23 Apr 2018 15:23:22 +0200 Subject: [PATCH 10/17] ComboBoxSearch: Add tests --- Orange/widgets/utils/tests/test_combobox.py | 146 ++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 Orange/widgets/utils/tests/test_combobox.py diff --git a/Orange/widgets/utils/tests/test_combobox.py b/Orange/widgets/utils/tests/test_combobox.py new file mode 100644 index 00000000000..1aa8bfe33b9 --- /dev/null +++ b/Orange/widgets/utils/tests/test_combobox.py @@ -0,0 +1,146 @@ +# pylint: disable=all + +import unittest + +from AnyQt.QtCore import Qt, QPoint, QRect +from AnyQt.QtGui import QMouseEvent +from AnyQt.QtWidgets import QListView, QApplication +from AnyQt.QtTest import QTest, QSignalSpy +from Orange.widgets.tests.base import GuiTest + +from Orange.widgets.utils import combobox + + +class TestComboBoxSearch(GuiTest): + def setUp(self): + super().setUp() + cb = combobox.ComboBoxSearch() + cb.addItem("One") + cb.addItem("Two") + cb.addItem("Three") + cb.insertSeparator(cb.count()) + cb.addItem("Four") + self.cb = cb + + def tearDown(self): + super().tearDown() + self.cb.deleteLater() + self.cb = None + + def test_combobox(self): + cb = self.cb + cb.grab() + cb.showPopup() + popup = cb.findChild(QListView) # type: QListView + # run through paint code for coverage + popup.grab() + cb.grab() + + model = popup.model() + self.assertEqual(model.rowCount(), cb.count()) + QTest.keyClick(popup, Qt.Key_E) + self.assertEqual(model.rowCount(), 2) + QTest.keyClick(popup, Qt.Key_Backspace) + self.assertEqual(model.rowCount(), cb.count()) + QTest.keyClick(popup, Qt.Key_F) + self.assertEqual(model.rowCount(), 1) + popup.setCurrentIndex(model.index(0, 0)) + spy = QSignalSpy(cb.activated[int]) + QTest.keyClick(popup, Qt.Key_Enter) + + self.assertEqual(spy[0], [4]) + self.assertEqual(cb.currentIndex(), 4) + self.assertEqual(cb.currentText(), "Four") + self.assertFalse(popup.isVisible()) + + def test_combobox_navigation(self): + cb = self.cb + cb.setCurrentIndex(4) + self.assertTrue(cb.currentText(), "Four") + cb.showPopup() + popup = cb.findChild(QListView) # type: QListView + self.assertEqual(popup.currentIndex().row(), 4) + + QTest.keyClick(popup, Qt.Key_Up) + self.assertEqual(popup.currentIndex().row(), 2) + QTest.keyClick(popup, Qt.Key_Escape) + self.assertFalse(popup.isVisible()) + self.assertEqual(cb.currentIndex(), 4) + cb.hidePopup() + + def test_click(self): + cb = self.cb + cb.showPopup() + popup = cb.findChild(QListView) # type: QListView + model = popup.model() + rect = popup.visualRect(model.index(1, 0)) + if hasattr(Qt, "WA_DontShowOnScreen"): + popup.window().setAttribute(Qt.WA_DontShowOnScreen, True) + spy = QSignalSpy(cb.activated[int]) + QTest.mouseClick(popup.viewport(), Qt.LeftButton, Qt.NoModifier, + rect.center(), QApplication.doubleClickInterval() + 10) + + self.assertEqual(spy[0], [1]) + self.assertEqual(cb.currentIndex(), 1) + self.assertEqual(cb.currentText(), "Two") + + def test_focus_out(self): + cb = self.cb + cb.showPopup() + popup = cb.findChild(QListView) + # Activate some other window to simulate focus out + w = QListView() + w.show() + w.activateWindow() + w.hide() + self.assertFalse(popup.isVisible()) + + def test_track(self): + cb = self.cb + cb.setStyleSheet("combobox-list-mousetracking: 1") + cb.showPopup() + popup = cb.findChild(QListView) # type: QListView + model = popup.model() + rect = popup.visualRect(model.index(2, 0)) + mouseMove(popup.viewport(), rect.center()) + self.assertEqual(popup.currentIndex().row(), 2) + cb.hidePopup() + + def test_empty(self): + cb = self.cb + cb.clear() + cb.showPopup() + popup = cb.findChild(QListView) # type: QListView + self.assertIsNone(popup) + + def test_popup_util(self): + geom = QRect(10, 10, 100, 400) + screen = QRect(0, 0, 600, 600) + g1 = combobox.dropdown_popup_geometry( + geom, QRect(200, 100, 100, 20), screen + ) + self.assertEqual(g1, QRect(200, 120, 100, 400)) + g2 = combobox.dropdown_popup_geometry( + geom, QRect(-10, 0, 100, 20), screen + ) + self.assertEqual(g2, QRect(0, 20, 100, 400)) + g3 = combobox.dropdown_popup_geometry( + geom, QRect(590, 0, 100, 20), screen + ) + self.assertEqual(g3, QRect(600 - 100, 20, 100, 400)) + g4 = combobox.dropdown_popup_geometry( + geom, QRect(0, 500, 100, 20), screen + ) + self.assertEqual(g4, QRect(0, 500 - 400, 100, 400)) + + +def mouseMove(widget, pos=QPoint(), delay=-1): # pragma: no-cover + # Like QTest.mouseMove, but functional without QCursor.setPos + if pos.isNull(): + pos = widget.rect().center() + me = QMouseEvent(QMouseEvent.MouseMove, pos, widget.mapToGlobal(pos), + Qt.NoButton, Qt.MouseButtons(0), Qt.NoModifier) + if delay > 0: + QTest.qWait(delay) + + QApplication.sendEvent(widget, me) From 56c3f7e445e7faab5625316e038885ab4af2b218 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 11 May 2018 16:06:29 +0200 Subject: [PATCH 11/17] ComboBoxSearch: Do nothing on showPopup calles if popup already visible --- Orange/widgets/utils/combobox.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py index ddeed527dc6..97146669c07 100644 --- a/Orange/widgets/utils/combobox.py +++ b/Orange/widgets/utils/combobox.py @@ -60,10 +60,9 @@ def showPopup(self): The .popup(), .lineEdit(), .completer() of the base class are not used. """ if self.__popup is not None: - popup = self.__popup - self.__popup = self.__proxy = None - popup.hide() - popup.deleteLater() + # We have user entered state that cannot be disturbed + # (entered filter text, scroll offset, ...) + return # pragma: no cover if self.count() == 0: return @@ -163,7 +162,7 @@ def hidePopup(self): popup.deleteLater() # need to call base hidePopup even though the base showPopup was not - # called (update internal state wrt. 'pressed' arrow, ... + # called (update internal state wrt. 'pressed' arrow, ...) super().hidePopup() self.__searchline.hide() self.update() From 2308c37bea7b839bc127e6a3c02ea4874a505600 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 25 May 2018 10:43:09 +0200 Subject: [PATCH 12/17] ComboBoxSearch: Remove use of WA_DoNotShowOnScreen Segfaults on headless xcb. --- Orange/widgets/utils/tests/test_combobox.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/Orange/widgets/utils/tests/test_combobox.py b/Orange/widgets/utils/tests/test_combobox.py index 1aa8bfe33b9..13f9574405f 100644 --- a/Orange/widgets/utils/tests/test_combobox.py +++ b/Orange/widgets/utils/tests/test_combobox.py @@ -74,8 +74,6 @@ def test_click(self): popup = cb.findChild(QListView) # type: QListView model = popup.model() rect = popup.visualRect(model.index(1, 0)) - if hasattr(Qt, "WA_DontShowOnScreen"): - popup.window().setAttribute(Qt.WA_DontShowOnScreen, True) spy = QSignalSpy(cb.activated[int]) QTest.mouseClick(popup.viewport(), Qt.LeftButton, Qt.NoModifier, rect.center(), QApplication.doubleClickInterval() + 10) From 10c45b717a191356915395dabc5cd4c63fd045f3 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 25 May 2018 11:03:33 +0200 Subject: [PATCH 13/17] ComboBoxSearch: Change test_click test Could segfault if the tested popup loses focus (is hidden and deleted) due to external factors. It is now run without any delay in the mouse event dispatch. --- Orange/widgets/utils/combobox.py | 4 ++-- Orange/widgets/utils/tests/test_combobox.py | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py index 97146669c07..f3b364c6579 100644 --- a/Orange/widgets/utils/combobox.py +++ b/Orange/widgets/utils/combobox.py @@ -231,8 +231,8 @@ def eventFilter(self, obj, event): # pylint: disable=too-many-branches if etype == QEvent.MouseButtonRelease and self.__popup is not None \ and obj is self.__popup.viewport() \ - and self.__popupTimer.hasExpired( - QApplication.doubleClickInterval()): + and self.__popupTimer.elapsed() >= \ + QApplication.doubleClickInterval(): event = event # type: QMouseEvent index = self.__popup.indexAt(event.pos()) if index.isValid(): diff --git a/Orange/widgets/utils/tests/test_combobox.py b/Orange/widgets/utils/tests/test_combobox.py index 13f9574405f..8ac1974deda 100644 --- a/Orange/widgets/utils/tests/test_combobox.py +++ b/Orange/widgets/utils/tests/test_combobox.py @@ -69,18 +69,21 @@ def test_combobox_navigation(self): cb.hidePopup() def test_click(self): + interval = QApplication.doubleClickInterval() + QApplication.setDoubleClickInterval(0) cb = self.cb + spy = QSignalSpy(cb.activated[int]) cb.showPopup() popup = cb.findChild(QListView) # type: QListView model = popup.model() - rect = popup.visualRect(model.index(1, 0)) - spy = QSignalSpy(cb.activated[int]) - QTest.mouseClick(popup.viewport(), Qt.LeftButton, Qt.NoModifier, - rect.center(), QApplication.doubleClickInterval() + 10) - - self.assertEqual(spy[0], [1]) - self.assertEqual(cb.currentIndex(), 1) - self.assertEqual(cb.currentText(), "Two") + rect = popup.visualRect(model.index(2, 0)) + QTest.mouseRelease( + popup.viewport(), Qt.LeftButton, Qt.NoModifier, rect.center() + ) + QApplication.setDoubleClickInterval(interval) + self.assertEqual(len(spy), 1) + self.assertEqual(spy[0], [2]) + self.assertEqual(cb.currentIndex(), 2) def test_focus_out(self): cb = self.cb From b9acb4c9313d4f4be2df7eedb5d8e4336bbb7c76 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 6 Jul 2018 13:27:24 +0200 Subject: [PATCH 14/17] ComboBoxSearch: Fix popup hiding on Windows The popup does not appear to hide automatically on windows when a mouse button is pressed outside the popup window. --- Orange/widgets/utils/combobox.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py index f3b364c6579..c95f058fb9f 100644 --- a/Orange/widgets/utils/combobox.py +++ b/Orange/widgets/utils/combobox.py @@ -250,6 +250,20 @@ def eventFilter(self, obj, event): # pylint: disable=too-many-branches index.flags() & (Qt.ItemIsEnabled | Qt.ItemIsSelectable): self.__popup.setCurrentIndex(index) + if etype == QEvent.MouseButtonPress and self.__popup is obj: + # Popup border or out of window mouse button press/release. + # At least on windows this needs to be handled. + style = self.style() + opt = QStyleOptionComboBox() + self.initStyleOption(opt) + opt.subControls = QStyle.SC_All + opt.activeSubControls = QStyle.SC_ComboBoxArrow + pos = self.mapFromGlobal(event.globalPos()) + sc = style.hitTestComplexControl(QStyle.CC_ComboBox, opt, pos, self) + if sc != QStyle.SC_None: + self.__popup.setAttribute(Qt.WA_NoMouseReplay) + self.hidePopup() + return super().eventFilter(obj, event) def __activateProxyIndex(self, index): From e0ebb589d8be53e4d55dc847241fc0eecabef245 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Thu, 9 Aug 2018 11:55:14 +0200 Subject: [PATCH 15/17] ComboBoxSearch: Remove event filters from popup widget on hide --- Orange/widgets/utils/combobox.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py index c95f058fb9f..d5f73902355 100644 --- a/Orange/widgets/utils/combobox.py +++ b/Orange/widgets/utils/combobox.py @@ -160,6 +160,8 @@ def hidePopup(self): popup.setFocusProxy(None) popup.hide() popup.deleteLater() + popup.removeEventFilter(self) + popup.viewport().removeEventFilter(self) # need to call base hidePopup even though the base showPopup was not # called (update internal state wrt. 'pressed' arrow, ...) From 5e41859729a15264a8e77c75b970124827d7a899 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Thu, 9 Aug 2018 11:59:20 +0200 Subject: [PATCH 16/17] ComboBoxSearch: Improve event dispatch Remove special casing Home/End key filter, dispatch directly to the line edit with no event propagation up the parent chain. --- Orange/widgets/utils/combobox.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py index d5f73902355..9bd9917333e 100644 --- a/Orange/widgets/utils/combobox.py +++ b/Orange/widgets/utils/combobox.py @@ -3,7 +3,7 @@ from AnyQt.QtCore import ( Qt, QEvent, QObject, QAbstractItemModel, QSortFilterProxyModel, - QModelIndex, QSize, QRect, QPoint, QMargins, QCoreApplication, QElapsedTimer + QModelIndex, QSize, QRect, QPoint, QMargins, QElapsedTimer ) from AnyQt.QtGui import QMouseEvent, QKeyEvent, QPainter, QPalette, QPen from AnyQt.QtWidgets import ( @@ -207,8 +207,8 @@ def eventFilter(self, obj, event): # pylint: disable=too-many-branches self.hidePopup() return False - if etype == QEvent.KeyPress or etype == QEvent.KeyRelease \ - and obj is self.__popup: + if etype == QEvent.KeyPress or etype == QEvent.KeyRelease or \ + etype == QEvent.ShortcutOverride and obj is self.__popup: event = event # type: QKeyEvent key, modifiers = event.key(), event.modifiers() if key in (Qt.Key_Enter, Qt.Key_Return, Qt.Key_Select): @@ -217,20 +217,19 @@ def eventFilter(self, obj, event): # pylint: disable=too-many-branches self.__activateProxyIndex(current) elif key in (Qt.Key_Up, Qt.Key_Down, Qt.Key_PageUp, Qt.Key_PageDown): - return False - elif key in (Qt.Key_End, Qt.Key_Home) \ - and not modifiers & Qt.ControlModifier: - return False + return False # elif key in (Qt.Key_Tab, Qt.Key_Backtab): pass elif key == Qt.Key_Escape or \ (key == Qt.Key_F4 and modifiers & Qt.AltModifier): self.__popup.hide() - else: - # pass the input events to the filter edit line - QCoreApplication.sendEvent(self.__searchline, event) return True - + else: + # pass the input events to the filter edit line (no propagation + # up the parent chain). + self.__searchline.event(event) + if event.isAccepted(): + return True if etype == QEvent.MouseButtonRelease and self.__popup is not None \ and obj is self.__popup.viewport() \ and self.__popupTimer.elapsed() >= \ From 8b3e8a1f7e75cfaa9e9fc5d6236925849aef9f45 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Thu, 9 Aug 2018 12:14:55 +0200 Subject: [PATCH 17/17] ComboBoxSearch: Improve separator line styling --- Orange/widgets/utils/combobox.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py index 9bd9917333e..0047ff646c0 100644 --- a/Orange/widgets/utils/combobox.py +++ b/Orange/widgets/utils/combobox.py @@ -19,7 +19,8 @@ def paint(self, painter, option, index): super().paint(painter, option, index) if index.data(Qt.AccessibleDescriptionRole) == "separator": palette = option.palette # type: QPalette - painter.setPen(QPen(palette.dark(), 1.0)) + brush = palette.brush(QPalette.Disabled, QPalette.Foreground) + painter.setPen(QPen(brush, 1.0)) rect = option.rect # type: QRect y = rect.center().y() painter.drawLine(rect.left(), y, rect.left() + rect.width(), y)