source: orange/Orange/OrangeCanvas/document/quickmenu.py @ 11164:58a1f1863e0d

Revision 11164:58a1f1863e0d, 21.3 KB checked in by Ales Erjavec <ales.erjavec@…>, 18 months ago (diff)

Filter suggested widgets by compatible channel types.

Line 
1"""
2Quick widget selector menu for the canvas.
3
4"""
5import sys
6import logging
7
8from collections import namedtuple
9
10from PyQt4.QtGui import (
11    QWidget, QFrame, QToolButton, QAbstractButton, QAction, QIcon,
12    QButtonGroup, QStackedWidget, QHBoxLayout, QVBoxLayout, QSizePolicy,
13    QStandardItemModel, QSortFilterProxyModel, QStyleOptionToolButton,
14    QStylePainter, QStyle, QApplication
15)
16
17from PyQt4.QtCore import pyqtSignal as Signal
18from PyQt4.QtCore import pyqtProperty as Property
19
20from PyQt4.QtCore import (
21    Qt, QObject, QPoint, QSize, QRect, QEventLoop, QEvent, QModelIndex
22)
23
24
25from ..gui.framelesswindow import FramelessWindow
26from ..gui.lineedit import LineEdit
27from ..gui.tooltree import ToolTree, FlattenedTreeItemModel
28from ..gui.utils import StyledWidget_paintEvent
29
30from ..registry.qt import QtWidgetRegistry
31
32from ..resources import icon_loader
33
34log = logging.getLogger(__name__)
35
36
37class SearchWidget(LineEdit):
38    def __init__(self, parent=None, **kwargs):
39        LineEdit.__init__(self, parent, **kwargs)
40        self.__setupUi()
41
42    def __setupUi(self):
43        icon = icon_loader().get("icons/Search.svg")
44        action = QAction(icon, "Search", self)
45
46        self.setAction(action, LineEdit.LeftPosition)
47
48
49class MenuStackWidget(QStackedWidget):
50    """Stack widget for the menu pages (ToolTree instances).
51    """
52
53    def sizeHint(self):
54        """Size hint is the median size hint of the widgets contained
55        within.
56
57        """
58        default_size = QSize(200, 400)
59        widget_hints = [default_size]
60        for i in range(self.count()):
61            w = self.widget(i)
62            if isinstance(w, ToolTree):
63                hint = self.__sizeHintForTreeView(w.view())
64            else:
65                hint = w.sizeHint()
66            widget_hints.append(hint)
67        width = max([s.width() for s in widget_hints])
68        # Take the median for the height
69        heights = sorted([s.height() for s in widget_hints])
70        height = heights[len(heights) / 2]
71        return QSize(width, height)
72
73    def __sizeHintForTreeView(self, view):
74        hint = view.sizeHint()
75        model = view.model()
76
77        count = model.rowCount()
78        width = view.sizeHintForColumn(0)
79
80        if count:
81            height = view.sizeHintForRow(0)
82            height = height * count
83        else:
84            height = hint.height()
85
86        return QSize(max(width, hint.width()), max(height, hint.height()))
87
88
89class TabButton(QToolButton):
90    def __init__(self, parent=None, **kwargs):
91        QToolButton.__init__(self, parent, **kwargs)
92        self.setToolButtonStyle(Qt.ToolButtonIconOnly)
93        self.setCheckable(True)
94
95        self.__flat = True
96
97    def setFlat(self, flat):
98        if self.__flat != flat:
99            self.__flat = flat
100            self.update()
101
102    def flat(self):
103        return self.__flat
104
105    flat_ = Property(bool, fget=flat, fset=setFlat,
106                     designable=True)
107
108    def paintEvent(self, event):
109        if self.__flat:
110            # Use default widget background/border styling.
111            StyledWidget_paintEvent(self, event)
112
113            opt = QStyleOptionToolButton()
114            self.initStyleOption(opt)
115            p = QStylePainter(self)
116            p.drawControl(QStyle.CE_ToolButtonLabel, opt)
117        else:
118            QToolButton.paintEvent(self, event)
119
120
121_Tab = \
122    namedtuple(
123        "_Tab",
124        ["text",
125         "icon",
126         "toolTip",
127         "button",
128         "data",
129         "palette"])
130
131
132class TabBarWidget(QWidget):
133    """A tab bar widget using tool buttons as tabs.
134
135    """
136    # TODO: A uniform size box layout.
137
138    currentChanged = Signal(int)
139
140    def __init__(self, parent=None, **kwargs):
141        QWidget.__init__(self, parent, **kwargs)
142        layout = QHBoxLayout()
143        layout.setContentsMargins(0, 0, 0, 0)
144        layout.setSpacing(0)
145        self.setLayout(layout)
146
147        self.setSizePolicy(QSizePolicy.Expanding,
148                           QSizePolicy.Fixed)
149        self.__tabs = []
150        self.__currentIndex = -1
151        self.__group = QButtonGroup(self, exclusive=True)
152        self.__group.buttonPressed[QAbstractButton].connect(
153            self.__onButtonPressed
154        )
155
156    def count(self):
157        """Return the number of tabs in the widget.
158        """
159        return len(self.__tabs)
160
161    def addTab(self, text, icon=None, toolTip=None):
162        """Add a tab and return it's index.
163        """
164        return self.insertTab(self.count(), text, icon, toolTip)
165
166    def insertTab(self, index, text, icon, toolTip):
167        """Insert a tab at `index`
168        """
169        button = TabButton(self, objectName="tab-button")
170        button.setSizePolicy(QSizePolicy.Expanding,
171                             QSizePolicy.Expanding)
172
173        self.__group.addButton(button)
174        tab = _Tab(text, icon, toolTip, button, None, None)
175        self.layout().insertWidget(index, button)
176
177        self.__tabs.insert(index, tab)
178        self.__updateTab(index)
179
180        if self.currentIndex() == -1:
181            self.setCurrentIndex(0)
182        return index
183
184    def removeTab(self, index):
185        if index >= 0 and index < self.count():
186            self.layout().takeItem(index)
187            tab = self.__tabs.pop(index)
188            self.__group.removeButton(tab.button)
189            tab.button.deleteLater()
190
191            if self.currentIndex() == index:
192                if self.count():
193                    self.setCurrentIndex(max(index - 1, 0))
194                else:
195                    self.setCurrentIndex(-1)
196
197    def setTabIcon(self, index, icon):
198        """Set the `icon` for tab at `index`.
199        """
200        self.__tabs[index] = self.__tabs[index]._replace(icon=icon)
201        self.__updateTab(index)
202
203    def setTabToolTip(self, index, toolTip):
204        """Set `toolTip` for tab at `index`.
205        """
206        self.__tabs[index] = self.__tabs[index]._replace(toolTip=toolTip)
207        self.__updateTab(index)
208
209    def setTabText(self, index, text):
210        """Set tab `text` for tab at `index`
211        """
212        self.__tabs[index] = self.__tabs[index]._replace(text=text)
213        self.__updateTab(index)
214
215    def setTabPalette(self, index, palette):
216        """Set the tab button palette.
217        """
218        self.__tabs[index] = self.__tabs[index]._replace(palette=palette)
219        self.__updateTab(index)
220
221    def setCurrentIndex(self, index):
222        if self.__currentIndex != index:
223            self.__currentIndex = index
224
225            if index != -1:
226                self.__tabs[index].button.setChecked(True)
227
228            self.currentChanged.emit(index)
229
230    def button(self, index):
231        """Return the `TabButton` instance for index.
232        """
233        return self.__tabs[index].button
234
235    def currentIndex(self):
236        """Return the current index.
237        """
238        return self.__currentIndex
239
240    def __updateTab(self, index):
241        """Update the tab button.
242        """
243        tab = self.__tabs[index]
244        b = tab.button
245
246        if tab.text:
247            b.setText(tab.text)
248
249        if tab.icon is not None and not tab.icon.isNull():
250            b.setIcon(tab.icon)
251
252        if tab.toolTip:
253            b.setToolTip(tab.toolTip)
254
255        if tab.palette:
256            b.setPalette(tab.palette)
257
258    def __onButtonPressed(self, button):
259        for i, tab in enumerate(self.__tabs):
260            if tab.button is button:
261                self.setCurrentIndex(i)
262                break
263
264
265class PagedMenu(QWidget):
266    """Tabed container for `ToolTree` instances.
267    """
268    triggered = Signal(QAction)
269    hovered = Signal(QAction)
270
271    currentChanged = Signal(int)
272
273    def __init__(self, parent=None, **kwargs):
274        QWidget.__init__(self, parent, **kwargs)
275
276        self.__pages = []
277        self.__currentIndex = -1
278
279        layout = QVBoxLayout()
280        layout.setContentsMargins(0, 0, 0, 0)
281        layout.setSpacing(0)
282
283        self.__tab = TabBarWidget(self)
284        self.__tab.setFixedHeight(25)
285        self.__tab.currentChanged.connect(self.setCurrentIndex)
286
287        self.__stack = MenuStackWidget(self)
288
289        layout.addWidget(self.__tab)
290        layout.addWidget(self.__stack)
291
292        self.setLayout(layout)
293
294    def addPage(self, page, title, icon=None, toolTip=None):
295        """Add a `page` to the menu and return its index.
296        """
297        return self.insertPage(self.count(), page, title, icon, toolTip)
298
299    def insertPage(self, index, page, title, icon=None, toolTip=None):
300        """Insert `page` at `index`.
301        """
302        page.triggered.connect(self.triggered)
303        page.hovered.connect(self.hovered)
304
305        self.__stack.insertWidget(index, page)
306        self.__tab.insertTab(index, title, icon, toolTip)
307        return index
308
309    def page(self, index):
310        """Return the page at index.
311        """
312        return self.__stack.widget(index)
313
314    def removePage(self, index):
315        """Remove the page at `index`.
316        """
317        page = self.__stack.widget(index)
318        page.triggered.disconnect(self.triggered)
319        page.hovered.disconnect(self.hovered)
320
321        self.__stack.removeWidget(page)
322        self.__tab.removeTab(index)
323
324    def count(self):
325        """Return the number of pages.
326        """
327        return self.__stack.count()
328
329    def setCurrentIndex(self, index):
330        """Set the current page index.
331        """
332        if self.__currentIndex != index:
333            self.__currentIndex = index
334            self.__tab.setCurrentIndex(index)
335            self.__stack.setCurrentIndex(index)
336            self.currentChanged.emit(index)
337
338    def currentIndex(self):
339        """Return the index of the current page.
340        """
341        return self.__currentIndex
342
343    def setCurrentPage(self, page):
344        """Set `page` to be the current shown page.
345        """
346        index = self.__stack.indexOf(page)
347        self.setCurrentIndex(index)
348
349    def currentPage(self):
350        """Return the current page.
351        """
352        return self.__stack.currentWidget()
353
354    def indexOf(self, page):
355        """Return the index of `page`.
356        """
357        return self.__stack.indexOf(page)
358
359    def tabButton(self, index):
360        """Return the tab button instance for index.
361        """
362        return self.__tab.button(index)
363
364
365class SortFilterProxyModel(QSortFilterProxyModel):
366    def __init__(self, parent=None):
367        QSortFilterProxyModel.__init__(self, parent)
368        self.__filterFunc = None
369
370    def filterAcceptsRow(self, row, parent=QModelIndex()):
371        accepted = QSortFilterProxyModel.filterAcceptsRow(self, row, parent)
372        if accepted and self.__filterFunc is not None:
373            model = self.sourceModel()
374            index = model.index(row, self.filterKeyColumn(), parent)
375            return self.__filterFunc(index)
376        else:
377            return accepted
378
379    def filterFunc(self):
380        return self.__filterFunc
381
382    def setFilterFunc(self, func):
383        if self.__filterFunc is not func:
384            self.__filterFunc = func
385            self.invalidateFilter()
386
387
388class SuggestMenuPage(ToolTree):
389    def __init__(self, *args, **kwargs):
390        ToolTree.__init__(self, *args, **kwargs)
391
392        # Make sure the initial model is wrapped in a FlattenedTreeItemModel.
393        self.setModel(self.model())
394
395    def setModel(self, model):
396        self.__sourceModel = model
397        flat = FlattenedTreeItemModel(self)
398        flat.setSourceModel(model)
399        flat.setFlatteningMode(flat.InternalNodesDisabled)
400        proxy = SortFilterProxyModel(self)
401        proxy.setFilterCaseSensitivity(False)
402        proxy.setSourceModel(flat)
403        ToolTree.setModel(self, proxy)
404        self.ensureCurrent()
405
406    def setFilterFixedString(self, pattern):
407        proxy = self.view().model()
408        proxy.setFilterFixedString(pattern)
409        self.ensureCurrent()
410
411    def setFilterRegExp(self, pattern):
412        filter_proxy = self.view().model()
413        filter_proxy.setFilterRegExp(pattern)
414        self.ensureCurrent()
415
416    def setFilterWildCard(self, pattern):
417        filter_proxy = self.view().model()
418        filter_proxy.setFilterWildCard(pattern)
419        self.ensureCurrent()
420
421    def setFilterFunc(self, func):
422        filter_proxy = self.view().model()
423        filter_proxy.setFilterFunc(func)
424
425
426class QuickMenu(FramelessWindow):
427    """A quick menu popup for the widgets.
428
429    The widgets are set using setModel which must be a
430    model as returned by QtWidgetRegistry.model()
431
432    """
433
434    triggered = Signal(QAction)
435    hovered = Signal(QAction)
436
437    def __init__(self, parent=None, **kwargs):
438        FramelessWindow.__init__(self, parent, **kwargs)
439        self.setWindowFlags(Qt.Popup)
440
441        self.__setupUi()
442
443        self.__loop = None
444        self.__model = QStandardItemModel()
445        self.__triggeredAction = None
446
447    def __setupUi(self):
448        self.setLayout(QVBoxLayout(self))
449        self.layout().setContentsMargins(6, 6, 6, 6)
450
451        self.__frame = QFrame(self, objectName="menu-frame")
452        layout = QVBoxLayout()
453        layout.setContentsMargins(1, 1, 1, 1)
454        layout.setSpacing(2)
455        self.__frame.setLayout(layout)
456
457        self.layout().addWidget(self.__frame)
458
459        self.__pages = PagedMenu(self, objectName="paged-menu")
460        self.__pages.currentChanged.connect(self.setCurrentIndex)
461        self.__pages.triggered.connect(self.triggered)
462        self.__pages.hovered.connect(self.hovered)
463
464        self.__frame.layout().addWidget(self.__pages)
465
466        self.__search = SearchWidget(self, objectName="search-line")
467
468        self.__search.setPlaceholderText(
469            self.tr("Search for widget or select from the list.")
470        )
471
472        self.layout().addWidget(self.__search)
473        self.setSizePolicy(QSizePolicy.Fixed,
474                           QSizePolicy.Expanding)
475
476        self.__suggestPage = SuggestMenuPage(self, objectName="suggest-page")
477        self.__suggestPage.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE)
478        self.__suggestPage.setIcon(icon_loader().get("icons/Search.svg"))
479
480        self.addPage(self.tr("Quick Search"), self.__suggestPage)
481
482        self.__search.textEdited.connect(
483            self.__suggestPage.setFilterFixedString
484        )
485
486        self.__navigator = ItemViewKeyNavigator(self)
487        self.__navigator.setView(self.__suggestPage.view())
488        self.__search.installEventFilter(self.__navigator)
489
490    def addPage(self, name, page):
491        """Add the page and return it's index.
492        """
493        icon = page.icon()
494
495        tip = name
496        if page.toolTip():
497            tip = page.toolTip()
498
499        index = self.__pages.addPage(page, name, icon, tip)
500        # TODO: get the background.
501
502        # Route the page's signals
503        page.triggered.connect(self.__onTriggered)
504        page.hovered.connect(self.hovered)
505        return index
506
507    def createPage(self, index):
508        page = ToolTree(self)
509        page.setModel(index.model())
510        page.setRootIndex(index)
511
512        view = page.view()
513
514        if sys.platform == "darwin":
515            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
516
517        name = unicode(index.data(Qt.DisplayRole))
518        page.setTitle(name)
519
520        icon = index.data(Qt.DecorationRole).toPyObject()
521        if isinstance(icon, QIcon):
522            page.setIcon(icon)
523
524        page.setToolTip(index.data(Qt.ToolTipRole).toPyObject())
525
526        return page
527
528    def setModel(self, model):
529        root = model.invisibleRootItem()
530        for i in range(root.rowCount()):
531            item = root.child(i)
532            index = item.index()
533            page = self.createPage(index)
534            page.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE)
535            i = self.addPage(page.title(), page)
536
537            brush = index.data(QtWidgetRegistry.BACKGROUND_ROLE)
538
539            if brush.isValid():
540                brush = brush.toPyObject()
541                button = self.__pages.tabButton(i)
542                palette = button.palette()
543                button.setStyleSheet(
544                    "QToolButton {\n"
545                    "    qproperty-flat_: false;"
546                    "    background-color: %s;\n"
547                    "    border: none;\n"
548                    "}\n"
549                    "QToolButton:checked {\n"
550                    "    border: 1px solid %s;\n"
551                    "}" % (brush.color().name(),
552                           palette.color(palette.Mid).name())
553                )
554
555        self.__model = model
556        self.__suggestPage.setModel(model)
557
558    def setFilterFunc(self, func):
559        self.__suggestPage.setFilterFunc(func)
560
561    def popup(self, pos=None):
562        """Popup the menu at `pos` (in screen coordinates)..
563        """
564        if pos is None:
565            pos = QPoint()
566
567        self.__search.setText("")
568        self.__suggestPage.setFilterFixedString("")
569
570        self.ensurePolished()
571        size = self.sizeHint()
572        desktop = QApplication.desktop()
573        screen_geom = desktop.availableGeometry(pos)
574
575        # Adjust the size to fit inside the screen.
576        if size.height() > screen_geom.height():
577            size.setHeight(screen_geom.height())
578        if size.width() > screen_geom.width():
579            size.setWidth(screen_geom.width())
580
581        geom = QRect(pos, size)
582
583        if geom.top() < screen_geom.top():
584            geom.setTop(screen_geom.top())
585
586        if geom.left() < screen_geom.left():
587            geom.setLeft(screen_geom.left())
588
589        bottom_margin = screen_geom.bottom() - geom.bottom()
590        right_margin = screen_geom.right() - geom.right()
591        if bottom_margin < 0:
592            # Falls over the bottom of the screen, move it up.
593            geom.translate(0, bottom_margin)
594
595        # TODO: right to left locale
596        if right_margin < 0:
597            # Falls over the right screen edge, move the menu to the
598            # other side of pos.
599            geom.translate(-size.width(), 0)
600
601        self.setGeometry(geom)
602
603        self.show()
604
605    def exec_(self, pos=None):
606        self.popup(pos)
607        self.setFocus(Qt.PopupFocusReason)
608
609        self.__triggeredAction = None
610        self.__loop = QEventLoop(self)
611        self.__loop.exec_()
612        self.__loop.deleteLater()
613        self.__loop = None
614
615        action = self.__triggeredAction
616        self.__triggeredAction = None
617        return action
618
619    def hideEvent(self, event):
620        FramelessWindow.hideEvent(self, event)
621        if self.__loop:
622            self.__loop.exit()
623
624    def setCurrentPage(self, page):
625        self.__pages.setCurrentPage(page)
626
627    def setCurrentIndex(self, index):
628        self.__pages.setCurrentIndex(index)
629
630    def __onTriggered(self, action):
631        """Re-emit the action from the page.
632        """
633        self.__triggeredAction = action
634
635        # Hide and exit the event loop if necessary.
636        self.hide()
637        self.triggered.emit(action)
638
639    def triggerSearch(self):
640        self.__pages.setCurrentWidget(self.__suggestPage)
641        self.__search.setFocus(Qt.ShortcutFocusReason)
642
643        # Make sure that the first enabled item is set current.
644        self.__suggestPage.ensureCurrent()
645
646    def keyPressEvent(self, event):
647        if event.text():
648            # Ignore modifiers etc.
649            self.__search.setFocus(Qt.ShortcutFocusReason)
650            self.setCurrentIndex(0)
651            self.__search.keyPressEvent(event)
652
653        FramelessWindow.keyPressEvent(self, event)
654
655
656class ItemViewKeyNavigator(QObject):
657    def __init__(self, parent=None):
658        QObject.__init__(self, parent)
659        self.__view = None
660
661    def setView(self, view):
662        if self.__view != view:
663            self.__view = view
664
665    def view(self):
666        return self.__view
667
668    def eventFilter(self, obj, event):
669        etype = event.type()
670        if etype == QEvent.KeyPress:
671            key = event.key()
672            if key == Qt.Key_Down:
673                self.moveCurrent(1, 0)
674                return True
675            elif key == Qt.Key_Up:
676                self.moveCurrent(-1, 0)
677                return True
678            elif key == Qt.Key_Tab:
679                self.moveCurrent(0, 1)
680                return  True
681            elif key == Qt.Key_Enter or key == Qt.Key_Return:
682                self.activateCurrent()
683                return True
684
685        return QObject.eventFilter(self, obj, event)
686
687    def moveCurrent(self, rows, columns=0):
688        """Move the current index by rows, columns.
689        """
690        if self.__view is not None:
691            view = self.__view
692            model = view.model()
693
694            curr = view.currentIndex()
695            curr_row, curr_col = curr.row(), curr.column()
696
697            sign = 1 if rows >= 0 else -1
698            row = curr_row + rows
699
700            row_count = model.rowCount()
701            for i in range(row_count):
702                index = model.index((row + sign * i) % row_count, 0)
703                if index.flags() & Qt.ItemIsEnabled:
704                    view.setCurrentIndex(index)
705                    break
706            # TODO: move by columns
707
708    def activateCurrent(self):
709        """Activate the current index.
710        """
711        if self.__view is not None:
712            curr = self.__view.currentIndex()
713            if curr.isValid():
714                # TODO: Does this work
715                self.__view.activated.emit(curr)
716
717    def ensureCurrent(self):
718        """Ensure the view has a current item if one is available.
719        """
720        if self.__view is not None:
721            model = self.__view.model()
722            curr = self.__view.currentIndex()
723            if not curr.isValid():
724                for i in range(model.rowCount()):
725                    index = model.index(i, 0)
726                    if index.flags() & Qt.ItemIsEnabled:
727                        self.__view.setCurrentIndex(index)
728                        break
Note: See TracBrowser for help on using the repository browser.