source: orange/Orange/OrangeCanvas/document/quickmenu.py @ 11524:73f80557851f

Revision 11524:73f80557851f, 38.7 KB checked in by Ales Erjavec <ales.erjavec@…>, 11 months ago (diff)

Fix for a possible segfault while filtering the items in a menu.

Line 
1"""
2==========
3Quick Menu
4==========
5
6A :class:`QuickMenu` widget provides lists of actions organized in tabs
7with a quick search functionality.
8
9"""
10
11import sys
12import logging
13
14from collections import namedtuple, Callable
15
16import numpy
17
18from PyQt4.QtGui import (
19    QWidget, QFrame, QToolButton, QAbstractButton, QAction, QIcon, QTreeView,
20    QButtonGroup, QStackedWidget, QHBoxLayout, QVBoxLayout, QSizePolicy,
21    QStandardItemModel, QSortFilterProxyModel, QStyleOptionToolButton,
22    QStylePainter, QStyle, QApplication, QStyledItemDelegate,
23    QStyleOptionViewItemV4, QSizeGrip, QPolygon, QRegion, QItemSelectionModel
24)
25
26from PyQt4.QtCore import pyqtSignal as Signal
27from PyQt4.QtCore import pyqtProperty as Property
28
29from PyQt4.QtCore import (
30    Qt, QObject, QPoint, QSize, QRect, QEventLoop, QEvent, QModelIndex,
31    QTimer
32)
33
34
35from ..gui.framelesswindow import FramelessWindow
36from ..gui.lineedit import LineEdit
37from ..gui.tooltree import ToolTree, FlattenedTreeItemModel
38from ..gui.utils import StyledWidget_paintEvent, create_css_gradient
39
40from ..registry.qt import QtWidgetRegistry
41
42from ..resources import icon_loader
43
44log = logging.getLogger(__name__)
45
46
47class _MenuItemDelegate(QStyledItemDelegate):
48    def __init__(self, parent=None):
49        QStyledItemDelegate.__init__(self, parent)
50
51    def sizeHint(self, option, index):
52        option = QStyleOptionViewItemV4(option)
53        self.initStyleOption(option, index)
54        size = QStyledItemDelegate.sizeHint(self, option, index)
55
56        # TODO: get the default QMenu item height from the current style.
57        size.setHeight(max(size.height(), 25))
58        return size
59
60
61class MenuPage(ToolTree):
62    """
63    A menu page in a :class:`QuickMenu` widget, showing a list of actions.
64    Shown actions can be disabled by setting a filtering function using the
65    :func:`setFilterFunc`.
66
67    """
68    def __init__(self, parent=None, title=None, icon=None, **kwargs):
69        ToolTree.__init__(self, parent, **kwargs)
70
71        if title is None:
72            title = ""
73
74        if icon is None:
75            icon = QIcon()
76
77        self.__title = title
78        self.__icon = icon
79
80        self.view().setItemDelegate(_MenuItemDelegate(self.view()))
81        self.view().entered.connect(self.__onEntered)
82        self.view().viewport().setMouseTracking(True)
83        # Make sure the initial model is wrapped in a ItemDisableFilter.
84        self.setModel(self.model())
85
86    def setTitle(self, title):
87        """
88        Set the title of the page.
89        """
90        if self.__title != title:
91            self.__title = title
92            self.update()
93
94    def title(self):
95        """
96        Return the title of this page.
97        """
98        return self.__title
99
100    title_ = Property(unicode, fget=title, fset=setTitle,
101                      doc="Title of the page.")
102
103    def setIcon(self, icon):
104        """
105        Set icon for this menu page.
106        """
107        if self.__icon != icon:
108            self.__icon = icon
109            self.update()
110
111    def icon(self):
112        """
113        Return the icon of this manu page.
114        """
115        return self.__icon
116
117    icon_ = Property(QIcon, fget=icon, fset=setIcon,
118                     doc="Page icon")
119
120    def setFilterFunc(self, func):
121        """
122        Set the filtering function. `func` should a function taking a single
123        :class:`QModelIndex` argument and returning True if the item at index
124        should be disabled and False otherwise. To disable filtering `func` can
125        be set to ``None``.
126
127        """
128        proxyModel = self.view().model()
129        proxyModel.setFilterFunc(func)
130
131    def setModel(self, model):
132        """
133        Reimplemented from :func:`ToolTree.setModel`.
134        """
135        proxyModel = ItemDisableFilter(self)
136        proxyModel.setSourceModel(model)
137        ToolTree.setModel(self, proxyModel)
138
139    def setRootIndex(self, index):
140        """
141        Reimplemented from :func:`ToolTree.setRootIndex`
142        """
143        proxyModel = self.view().model()
144        mappedIndex = proxyModel.mapFromSource(index)
145        ToolTree.setRootIndex(self, mappedIndex)
146
147    def rootIndex(self):
148        """
149        Reimplemented from :func:`ToolTree.rootIndex`
150        """
151        proxyModel = self.view().model()
152        return proxyModel.mapToSource(ToolTree.rootIndex(self))
153
154    def sizeHint(self):
155        view = self.view()
156        model = view.model()
157
158        # This will not work for nested items (tree).
159        count = model.rowCount(view.rootIndex())
160
161        width = view.sizeHintForColumn(0)
162
163        if count:
164            height = view.sizeHintForRow(0)
165            height = height * count
166        else:
167            height = 0
168        return QSize(width, height)
169
170    def __onEntered(self, index):
171        if not index.isValid():
172            return
173
174        if self.view().state() != QTreeView.NoState:
175            # The item view can emit an 'entered' signal while the model/view
176            # is being changed (rows removed). When this happens, setting the
177            # current item can segfault (in QTreeView::scrollTo).
178            return
179
180        if index.flags() & Qt.ItemIsEnabled:
181            self.view().selectionModel().setCurrentIndex(
182                index,
183                QItemSelectionModel.ClearAndSelect
184            )
185
186
187class ItemDisableFilter(QSortFilterProxyModel):
188    """
189    An filter proxy model used to disable selected items based on
190    a filtering function.
191
192    """
193    def __init__(self, parent=None):
194        QSortFilterProxyModel.__init__(self, parent)
195
196        self.__filterFunc = None
197
198    def setFilterFunc(self, func):
199        """
200        Set the filtering function.
201        """
202        if not (isinstance(func, Callable) or func is None):
203            raise ValueError("A callable object or None expected.")
204
205        if self.__filterFunc != func:
206            self.__filterFunc = func
207            # Mark the whole model as changed.
208            self.dataChanged.emit(self.index(0, 0),
209                                  self.index(self.rowCount(), 0))
210
211    def flags(self, index):
212        """
213        Reimplemented from :class:`QSortFilterProxyModel.flags`
214        """
215        source = self.mapToSource(index)
216        flags = source.flags()
217
218        if self.__filterFunc is not None:
219            enabled = flags & Qt.ItemIsEnabled
220            if enabled and not self.__filterFunc(source):
221                flags ^= Qt.ItemIsEnabled
222
223        return flags
224
225
226class SuggestMenuPage(MenuPage):
227    """
228    A MenuMage for the QuickMenu widget supporting item filtering
229    (searching).
230
231    """
232    def __init__(self, *args, **kwargs):
233        MenuPage.__init__(self, *args, **kwargs)
234
235    def setModel(self, model):
236        """
237        Reimplmemented from :ref:`MenuPage.setModel`.
238        """
239        flat = FlattenedTreeItemModel(self)
240        flat.setSourceModel(model)
241        flat.setFlatteningMode(flat.InternalNodesDisabled)
242        flat.setFlatteningMode(flat.LeavesOnly)
243        proxy = SortFilterProxyModel(self)
244        proxy.setFilterCaseSensitivity(False)
245        proxy.setSourceModel(flat)
246        ToolTree.setModel(self, proxy)
247        self.ensureCurrent()
248
249    def setFilterFixedString(self, pattern):
250        """
251        Set the fixed string filtering pattern. Only items which contain the
252        `pattern` string will be shown.
253
254        """
255        proxy = self.view().model()
256        proxy.setFilterFixedString(pattern)
257        self.ensureCurrent()
258
259    def setFilterRegExp(self, pattern):
260        """
261        Set the regular expression filtering pattern. Only items matching
262        the `pattern` expression will be shown.
263
264        """
265        filter_proxy = self.view().model()
266        filter_proxy.setFilterRegExp(pattern)
267        self.ensureCurrent()
268
269    def setFilterWildCard(self, pattern):
270        """
271        Set a wildcard filtering pattern.
272        """
273        filter_proxy = self.view().model()
274        filter_proxy.setFilterWildCard(pattern)
275        self.ensureCurrent()
276
277    def setFilterFunc(self, func):
278        """
279        Set a filtering function.
280        """
281        filter_proxy = self.view().model()
282        filter_proxy.setFilterFunc(func)
283
284
285class SortFilterProxyModel(QSortFilterProxyModel):
286    """
287    An filter proxy model used to filter items based on a filtering
288    function.
289
290    """
291    def __init__(self, parent=None):
292        QSortFilterProxyModel.__init__(self, parent)
293
294        self.__filterFunc = None
295
296    def setFilterFunc(self, func):
297        """
298        Set the filtering function.
299        """
300        if not (isinstance(func, Callable) or func is None):
301            raise ValueError("A callable object or None expected.")
302
303        if self.__filterFunc is not func:
304            self.__filterFunc = func
305            self.invalidateFilter()
306
307    def filterFunc(self):
308        return self.__filterFunc
309
310    def filterAcceptsRow(self, row, parent=QModelIndex()):
311        accepted = QSortFilterProxyModel.filterAcceptsRow(self, row, parent)
312        if accepted and self.__filterFunc is not None:
313            model = self.sourceModel()
314            index = model.index(row, self.filterKeyColumn(), parent)
315            return self.__filterFunc(index)
316        else:
317            return accepted
318
319
320class SearchWidget(LineEdit):
321    def __init__(self, parent=None, **kwargs):
322        LineEdit.__init__(self, parent, **kwargs)
323        self.__setupUi()
324
325    def __setupUi(self):
326        icon = icon_loader().get("icons/Search.svg")
327        action = QAction(icon, "Search", self)
328        self.setAction(action, LineEdit.LeftPosition)
329
330
331class MenuStackWidget(QStackedWidget):
332    """
333    Stack widget for the menu pages.
334    """
335
336    def sizeHint(self):
337        """
338        Size hint is the maximum width and median height of the widgets
339        contained in the stack.
340
341        """
342        default_size = QSize(200, 400)
343        widget_hints = [default_size]
344        for i in range(self.count()):
345            hint = self.widget(i).sizeHint()
346            widget_hints.append(hint)
347
348        width = max([s.width() for s in widget_hints])
349        # Take the median for the height
350        height = numpy.median([s.height() for s in widget_hints])
351
352        return QSize(width, int(height))
353
354    def __sizeHintForTreeView(self, view):
355        hint = view.sizeHint()
356        model = view.model()
357
358        count = model.rowCount()
359        width = view.sizeHintForColumn(0)
360
361        if count:
362            height = view.sizeHintForRow(0)
363            height = height * count
364        else:
365            height = hint.height()
366
367        return QSize(max(width, hint.width()), max(height, hint.height()))
368
369
370class TabButton(QToolButton):
371    def __init__(self, parent=None, **kwargs):
372        QToolButton.__init__(self, parent, **kwargs)
373        self.setToolButtonStyle(Qt.ToolButtonIconOnly)
374        self.setCheckable(True)
375
376        self.__flat = True
377        self.__showMenuIndicator = False
378
379    def setFlat(self, flat):
380        if self.__flat != flat:
381            self.__flat = flat
382            self.update()
383
384    def flat(self):
385        return self.__flat
386
387    flat_ = Property(bool, fget=flat, fset=setFlat,
388                     designable=True)
389
390    def setShownMenuIndicator(self, show):
391        if self.__showMenuIndicator != show:
392            self.__showMenuIndicator = show
393            self.update()
394
395    def showMenuIndicator(self):
396        return self.__showMenuIndicator
397
398    showMenuIndicator_ = Property(bool, fget=showMenuIndicator,
399                                  fset=setShownMenuIndicator,
400                                  designable=True)
401
402    def paintEvent(self, event):
403        opt = QStyleOptionToolButton()
404        self.initStyleOption(opt)
405        if self.__showMenuIndicator and self.isChecked():
406            opt.features |= QStyleOptionToolButton.HasMenu
407        if self.__flat:
408            # Use default widget background/border styling.
409            StyledWidget_paintEvent(self, event)
410
411            p = QStylePainter(self)
412            p.drawControl(QStyle.CE_ToolButtonLabel, opt)
413        else:
414            p = QStylePainter(self)
415            p.drawComplexControl(QStyle.CC_ToolButton, opt)
416
417    def sizeHint(self):
418        opt = QStyleOptionToolButton()
419        self.initStyleOption(opt)
420        if self.__showMenuIndicator and self.isChecked():
421            opt.features |= QStyleOptionToolButton.HasMenu
422        style = self.style()
423
424        hint = style.sizeFromContents(QStyle.CT_ToolButton, opt,
425                                      opt.iconSize, self)
426        return hint
427
428_Tab = \
429    namedtuple(
430        "_Tab",
431        ["text",
432         "icon",
433         "toolTip",
434         "button",
435         "data",
436         "palette"])
437
438
439class TabBarWidget(QWidget):
440    """
441    A tab bar widget using tool buttons as tabs.
442    """
443    # TODO: A uniform size box layout.
444
445    currentChanged = Signal(int)
446
447    def __init__(self, parent=None, **kwargs):
448        QWidget.__init__(self, parent, **kwargs)
449        layout = QVBoxLayout()
450        layout.setContentsMargins(0, 0, 0, 0)
451        layout.setSpacing(0)
452        self.setLayout(layout)
453
454        self.setSizePolicy(QSizePolicy.Fixed,
455                           QSizePolicy.Expanding)
456        self.__tabs = []
457
458        self.__currentIndex = -1
459        self.__changeOnHover = False
460
461        self.__iconSize = QSize(26, 26)
462
463        self.__group = QButtonGroup(self, exclusive=True)
464        self.__group.buttonPressed[QAbstractButton].connect(
465            self.__onButtonPressed
466        )
467        self.setMouseTracking(True)
468
469        self.__sloppyButton = None
470        self.__sloppyRegion = QRegion()
471        self.__sloppyTimer = QTimer(self, singleShot=True)
472        self.__sloppyTimer.timeout.connect(self.__onSloppyTimeout)
473
474    def setChangeOnHover(self, changeOnHover):
475        """
476        If set to ``True`` the tab widget will change the current index when
477        the mouse hovers over a tab button.
478
479        """
480        if self.__changeOnHover != changeOnHover:
481            self.__changeOnHover = changeOnHover
482
483    def changeOnHover(self):
484        """
485        Does the current tab index follow the mouse cursor.
486        """
487        return self.__changeOnHover
488
489    def count(self):
490        """
491        Return the number of tabs in the widget.
492        """
493        return len(self.__tabs)
494
495    def addTab(self, text, icon=None, toolTip=None):
496        """
497        Add a new tab and return it's index.
498        """
499        return self.insertTab(self.count(), text, icon, toolTip)
500
501    def insertTab(self, index, text, icon=None, toolTip=None):
502        """
503        Insert a tab at `index`
504        """
505        button = TabButton(self, objectName="tab-button")
506        button.setSizePolicy(QSizePolicy.Expanding,
507                             QSizePolicy.Expanding)
508        button.setIconSize(self.__iconSize)
509        button.setMouseTracking(True)
510
511        self.__group.addButton(button)
512
513        button.installEventFilter(self)
514
515        tab = _Tab(text, icon, toolTip, button, None, None)
516        self.layout().insertWidget(index, button)
517
518        self.__tabs.insert(index, tab)
519        self.__updateTab(index)
520
521        if self.currentIndex() == -1:
522            self.setCurrentIndex(0)
523        return index
524
525    def removeTab(self, index):
526        """
527        Remove a tab at `index`.
528        """
529        if index >= 0 and index < self.count():
530            self.layout().takeItem(index)
531            tab = self.__tabs.pop(index)
532            self.__group.removeButton(tab.button)
533
534            tab.button.removeEventFilter(self)
535
536            if tab.button is self.__sloppyButton:
537                self.__sloppyButton = None
538                self.__sloppyRegion = QRegion()
539
540            tab.button.deleteLater()
541
542            if self.currentIndex() == index:
543                if self.count():
544                    self.setCurrentIndex(max(index - 1, 0))
545                else:
546                    self.setCurrentIndex(-1)
547
548    def setTabIcon(self, index, icon):
549        """
550        Set the `icon` for tab at `index`.
551        """
552        self.__tabs[index] = self.__tabs[index]._replace(icon=icon)
553        self.__updateTab(index)
554
555    def setTabToolTip(self, index, toolTip):
556        """
557        Set `toolTip` for tab at `index`.
558        """
559        self.__tabs[index] = self.__tabs[index]._replace(toolTip=toolTip)
560        self.__updateTab(index)
561
562    def setTabText(self, index, text):
563        """
564        Set tab `text` for tab at `index`
565        """
566        self.__tabs[index] = self.__tabs[index]._replace(text=text)
567        self.__updateTab(index)
568
569    def setTabPalette(self, index, palette):
570        """
571        Set the tab button palette.
572        """
573        self.__tabs[index] = self.__tabs[index]._replace(palette=palette)
574        self.__updateTab(index)
575
576    def setCurrentIndex(self, index):
577        """
578        Set the current tab index.
579        """
580        if self.__currentIndex != index:
581            self.__currentIndex = index
582
583            self.__sloppyRegion = QRegion()
584            self.__sloppyButton = None
585
586            if index != -1:
587                self.__tabs[index].button.setChecked(True)
588
589            self.currentChanged.emit(index)
590
591    def currentIndex(self):
592        """
593        Return the current index.
594        """
595        return self.__currentIndex
596
597    def button(self, index):
598        """
599        Return the `TabButton` instance for index.
600        """
601        return self.__tabs[index].button
602
603    def setIconSize(self, size):
604        if self.__iconSize != size:
605            self.__iconSize = size
606            for tab in self.__tabs:
607                tab.button.setIconSize(self.__iconSize)
608
609    def __updateTab(self, index):
610        """
611        Update the tab button.
612        """
613        tab = self.__tabs[index]
614        b = tab.button
615
616        if tab.text:
617            b.setText(tab.text)
618
619        if tab.icon is not None and not tab.icon.isNull():
620            b.setIcon(tab.icon)
621
622        if tab.palette:
623            b.setPalette(tab.palette)
624
625    def __onButtonPressed(self, button):
626        for i, tab in enumerate(self.__tabs):
627            if tab.button is button:
628                self.setCurrentIndex(i)
629                break
630
631    def __calcSloppyRegion(self, current):
632        """
633        Given a current mouse cursor position return a region of the widget
634        where hover/move events should change the current tab only on a
635        timeout.
636
637        """
638        p1 = current + QPoint(0, 2)
639        p2 = current + QPoint(0, -2)
640        p3 = self.pos() + QPoint(self.width()+10, 0)
641        p4 = self.pos() + QPoint(self.width()+10, self.height())
642        return QRegion(QPolygon([p1, p2, p3, p4]))
643
644    def __setSloppyButton(self, button):
645        """
646        Set the current sloppy button (a tab button inside sloppy region)
647        and reset the sloppy timeout.
648
649        """
650        if not button.isChecked():
651            self.__sloppyButton = button
652            delay = self.style().styleHint(QStyle.SH_Menu_SubMenuPopupDelay, None)
653            # The delay timeout is the same as used by Qt in the QMenu.
654            self.__sloppyTimer.start(delay)
655        else:
656            self.__sloppyTimer.stop()
657
658    def __onSloppyTimeout(self):
659        if self.__sloppyButton is not None:
660            button = self.__sloppyButton
661            self.__sloppyButton = None
662            if not button.isChecked():
663                index = [tab.button for tab in self.__tabs].index(button)
664                self.setCurrentIndex(index)
665
666    def eventFilter(self, receiver, event):
667        if event.type() == QEvent.MouseMove and \
668                isinstance(receiver, TabButton):
669            pos = receiver.mapTo(self, event.pos())
670            if self.__sloppyRegion.contains(pos):
671                self.__setSloppyButton(receiver)
672            else:
673                if not receiver.isChecked():
674                    index = [tab.button for tab in self.__tabs].index(receiver)
675                    self.setCurrentIndex(index)
676                #also update sloppy region if mouse is moved on the same icon
677                self.__sloppyRegion = self.__calcSloppyRegion(pos)
678
679        return QWidget.eventFilter(self, receiver, event)
680
681    def leaveEvent(self, event):
682        self.__sloppyButton = None
683        self.__sloppyRegion = QRegion()
684
685        return QWidget.leaveEvent(self, event)
686
687
688class PagedMenu(QWidget):
689    """
690    Tabbed container for :class:`MenuPage` instances.
691    """
692    triggered = Signal(QAction)
693    hovered = Signal(QAction)
694
695    currentChanged = Signal(int)
696
697    def __init__(self, parent=None, **kwargs):
698        QWidget.__init__(self, parent, **kwargs)
699
700        self.__pages = []
701        self.__currentIndex = -1
702
703        layout = QHBoxLayout()
704        layout.setContentsMargins(0, 0, 0, 0)
705        layout.setSpacing(6)
706
707        self.__tab = TabBarWidget(self)
708        self.__tab.currentChanged.connect(self.setCurrentIndex)
709        self.__tab.setChangeOnHover(True)
710
711        self.__stack = MenuStackWidget(self)
712
713        layout.addWidget(self.__tab, alignment=Qt.AlignTop)
714        layout.addWidget(self.__stack)
715
716        self.setLayout(layout)
717
718    def addPage(self, page, title, icon=None, toolTip=None):
719        """
720        Add a `page` to the menu and return its index.
721        """
722        return self.insertPage(self.count(), page, title, icon, toolTip)
723
724    def insertPage(self, index, page, title, icon=None, toolTip=None):
725        """
726        Insert `page` at `index`.
727        """
728        page.triggered.connect(self.triggered)
729        page.hovered.connect(self.hovered)
730
731        self.__stack.insertWidget(index, page)
732        self.__tab.insertTab(index, title, icon, toolTip)
733        return index
734
735    def page(self, index):
736        """
737        Return the page at index.
738        """
739        return self.__stack.widget(index)
740
741    def removePage(self, index):
742        """
743        Remove the page at `index`.
744        """
745        page = self.__stack.widget(index)
746        page.triggered.disconnect(self.triggered)
747        page.hovered.disconnect(self.hovered)
748
749        self.__stack.removeWidget(page)
750        self.__tab.removeTab(index)
751
752    def count(self):
753        """
754        Return the number of pages.
755        """
756        return self.__stack.count()
757
758    def setCurrentIndex(self, index):
759        """
760        Set the current page index.
761        """
762        if self.__currentIndex != index:
763            self.__currentIndex = index
764            self.__tab.setCurrentIndex(index)
765            self.__stack.setCurrentIndex(index)
766            self.currentChanged.emit(index)
767
768    def currentIndex(self):
769        """
770        Return the index of the current page.
771        """
772        return self.__currentIndex
773
774    def setCurrentPage(self, page):
775        """
776        Set `page` to be the current shown page.
777        """
778        index = self.__stack.indexOf(page)
779        self.setCurrentIndex(index)
780
781    def currentPage(self):
782        """
783        Return the current page.
784        """
785        return self.__stack.currentWidget()
786
787    def indexOf(self, page):
788        """
789        Return the index of `page`.
790        """
791        return self.__stack.indexOf(page)
792
793    def tabButton(self, index):
794        """
795        Return the tab button instance for index.
796        """
797        return self.__tab.button(index)
798
799
800TAB_BUTTON_STYLE_TEMPLATE = """\
801TabButton {
802    qproperty-flat_: false;
803    background: %s;
804    border: none;
805    border-bottom: 1px solid palette(mid);
806}
807
808TabButton:checked {
809    background: %s;
810    border: none;
811    border-top: 1px solid #609ED7;
812    border-bottom: 1px solid #609ED7;
813}
814"""
815
816
817class QuickMenu(FramelessWindow):
818    """
819    A quick menu popup for the widgets.
820
821    The widgets are set using :func:`QuickMenu.setModel` which must be a
822    model as returned by :func:`QtWidgetRegistry.model`
823
824    """
825
826    #: An action has been triggered in the menu.
827    triggered = Signal(QAction)
828
829    #: An action has been hovered in the menu
830    hovered = Signal(QAction)
831
832    def __init__(self, parent=None, **kwargs):
833        FramelessWindow.__init__(self, parent, **kwargs)
834        self.setWindowFlags(Qt.Popup)
835
836        self.__filterFunc = None
837
838        self.__setupUi()
839
840        self.__loop = None
841        self.__model = QStandardItemModel()
842        self.__triggeredAction = None
843
844    def __setupUi(self):
845        self.setLayout(QVBoxLayout(self))
846        self.layout().setContentsMargins(6, 6, 6, 6)
847
848        self.__search = SearchWidget(self, objectName="search-line")
849
850        self.__search.setPlaceholderText(
851            self.tr("Search for widget or select from the list.")
852        )
853
854        self.layout().addWidget(self.__search)
855
856        self.__frame = QFrame(self, objectName="menu-frame")
857        layout = QVBoxLayout()
858        layout.setContentsMargins(0, 0, 0, 0)
859        layout.setSpacing(2)
860        self.__frame.setLayout(layout)
861
862        self.layout().addWidget(self.__frame)
863
864        self.__pages = PagedMenu(self, objectName="paged-menu")
865        self.__pages.currentChanged.connect(self.setCurrentIndex)
866        self.__pages.triggered.connect(self.triggered)
867        self.__pages.hovered.connect(self.hovered)
868
869        self.__frame.layout().addWidget(self.__pages)
870
871        self.setSizePolicy(QSizePolicy.Fixed,
872                           QSizePolicy.Expanding)
873
874        self.__suggestPage = SuggestMenuPage(self, objectName="suggest-page")
875        self.__suggestPage.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE)
876        self.__suggestPage.setIcon(icon_loader().get("icons/Search.svg"))
877
878        if sys.platform == "darwin":
879            view = self.__suggestPage.view()
880            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
881            # Don't show the focus frame because it expands into the tab bar.
882            view.setAttribute(Qt.WA_MacShowFocusRect, False)
883
884        i = self.addPage(self.tr("Quick Search"), self.__suggestPage)
885        button = self.__pages.tabButton(i)
886        button.setObjectName("search-tab-button")
887        button.setStyleSheet(
888            "TabButton {\n"
889            "    qproperty-flat_: false;\n"
890            "    border: none;"
891            "}\n")
892
893        self.__search.textEdited.connect(self.__on_textEdited)
894
895        self.__navigator = ItemViewKeyNavigator(self)
896        self.__navigator.setView(self.__suggestPage.view())
897        self.__search.installEventFilter(self.__navigator)
898
899        self.__grip = WindowSizeGrip(self)
900        self.__grip.raise_()
901
902    def setSizeGripEnabled(self, enabled):
903        """
904        Enable the resizing of the menu with a size grip in a bottom
905        right corner (enabled by default).
906
907        """
908        if bool(enabled) != bool(self.__grip):
909            if self.__grip:
910                self.__grip.deleteLater()
911                self.__grip = None
912            else:
913                self.__grip = WindowSizeGrip(self)
914                self.__grip.raise_()
915
916    def sizeGripEnabled(self):
917        """
918        Is the size grip enabled.
919        """
920        return bool(self.__grip)
921
922    def addPage(self, name, page):
923        """
924        Add the `page` (:class:`MenuPage`) with `name` and return it's index.
925        The `page.icon()` will be used as the icon in the tab bar.
926
927        """
928        icon = page.icon()
929
930        tip = name
931        if page.toolTip():
932            tip = page.toolTip()
933
934        index = self.__pages.addPage(page, name, icon, tip)
935
936        # Route the page's signals
937        page.triggered.connect(self.__onTriggered)
938        page.hovered.connect(self.hovered)
939
940        # Install event filter to intercept key presses.
941        page.view().installEventFilter(self)
942
943        return index
944
945    def createPage(self, index):
946        """
947        Create a new page based on the contents of an index
948        (:class:`QModeIndex`) item.
949
950        """
951        page = MenuPage(self)
952
953        page.setModel(index.model())
954        page.setRootIndex(index)
955
956        view = page.view()
957
958        if sys.platform == "darwin":
959            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
960            # Don't show the focus frame because it expands into the tab
961            # bar at the top.
962            view.setAttribute(Qt.WA_MacShowFocusRect, False)
963
964        name = unicode(index.data(Qt.DisplayRole))
965        page.setTitle(name)
966
967        icon = index.data(Qt.DecorationRole).toPyObject()
968        if isinstance(icon, QIcon):
969            page.setIcon(icon)
970
971        page.setToolTip(index.data(Qt.ToolTipRole).toPyObject())
972        return page
973
974    def setModel(self, model):
975        """
976        Set the model containing the actions.
977        """
978        root = model.invisibleRootItem()
979        for i in range(root.rowCount()):
980            item = root.child(i)
981            index = item.index()
982            page = self.createPage(index)
983            page.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE)
984            i = self.addPage(page.title(), page)
985
986            brush = index.data(QtWidgetRegistry.BACKGROUND_ROLE)
987
988            if brush.isValid():
989                brush = brush.toPyObject()
990                base_color = brush.color()
991                button = self.__pages.tabButton(i)
992                button.setStyleSheet(
993                    TAB_BUTTON_STYLE_TEMPLATE %
994                    (create_css_gradient(base_color),
995                     create_css_gradient(base_color.darker(120)))
996                )
997
998        self.__model = model
999        self.__suggestPage.setModel(model)
1000
1001    def setFilterFunc(self, func):
1002        """
1003        Set a filter function.
1004        """
1005        if func != self.__filterFunc:
1006            self.__filterFunc = func
1007            for i in range(0, self.__pages.count()):
1008                self.__pages.page(i).setFilterFunc(func)
1009
1010    def popup(self, pos=None, searchText=""):
1011        """
1012        Popup the menu at `pos` (in screen coordinates). 'Search' text field
1013        is initialized with `searchText` if provided.
1014
1015        """
1016        if pos is None:
1017            pos = QPoint()
1018
1019        self.__clearCurrentItems()
1020
1021        self.__search.setText(searchText)
1022        self.__suggestPage.setFilterFixedString(searchText)
1023
1024        self.ensurePolished()
1025
1026        if self.testAttribute(Qt.WA_Resized) and self.sizeGripEnabled():
1027            size = self.size()
1028        else:
1029            size = self.sizeHint()
1030
1031        desktop = QApplication.desktop()
1032        screen_geom = desktop.availableGeometry(pos)
1033
1034        # Adjust the size to fit inside the screen.
1035        if size.height() > screen_geom.height():
1036            size.setHeight(screen_geom.height())
1037        if size.width() > screen_geom.width():
1038            size.setWidth(screen_geom.width())
1039
1040        geom = QRect(pos, size)
1041
1042        if geom.top() < screen_geom.top():
1043            geom.setTop(screen_geom.top())
1044
1045        if geom.left() < screen_geom.left():
1046            geom.setLeft(screen_geom.left())
1047
1048        bottom_margin = screen_geom.bottom() - geom.bottom()
1049        right_margin = screen_geom.right() - geom.right()
1050        if bottom_margin < 0:
1051            # Falls over the bottom of the screen, move it up.
1052            geom.translate(0, bottom_margin)
1053
1054        # TODO: right to left locale
1055        if right_margin < 0:
1056            # Falls over the right screen edge, move the menu to the
1057            # other side of pos.
1058            geom.translate(-size.width(), 0)
1059
1060        self.setGeometry(geom)
1061
1062        self.show()
1063
1064        if searchText:
1065            self.setFocusProxy(self.__search)
1066        else:
1067            self.setFocusProxy(None)
1068
1069    def exec_(self, pos=None, searchText=""):
1070        """
1071        Execute the menu at position `pos` (in global screen coordinates).
1072        Return the triggered :class:`QAction` or `None` if no action was
1073        triggered. 'Search' text field is initialized with `searchText` if
1074        provided.
1075
1076        """
1077        self.popup(pos, searchText)
1078        self.setFocus(Qt.PopupFocusReason)
1079
1080        self.__triggeredAction = None
1081        self.__loop = QEventLoop()
1082        self.__loop.exec_()
1083        self.__loop.deleteLater()
1084        self.__loop = None
1085
1086        action = self.__triggeredAction
1087        self.__triggeredAction = None
1088        return action
1089
1090    def hideEvent(self, event):
1091        """
1092        Reimplemented from :class:`QWidget`
1093        """
1094        FramelessWindow.hideEvent(self, event)
1095        if self.__loop:
1096            self.__loop.exit()
1097
1098    def setCurrentPage(self, page):
1099        """
1100        Set the current shown page to `page`.
1101        """
1102        self.__pages.setCurrentPage(page)
1103
1104    def setCurrentIndex(self, index):
1105        """
1106        Set the current page index.
1107        """
1108        self.__pages.setCurrentIndex(index)
1109
1110    def __clearCurrentItems(self):
1111        """
1112        Clear any selected (or current) items in all the menus.
1113        """
1114        for i in range(self.__pages.count()):
1115            self.__pages.page(i).view().selectionModel().clear()
1116
1117    def __onTriggered(self, action):
1118        """
1119        Re-emit the action from the page.
1120        """
1121        self.__triggeredAction = action
1122
1123        # Hide and exit the event loop if necessary.
1124        self.hide()
1125        self.triggered.emit(action)
1126
1127    def __on_textEdited(self, text):
1128        self.__suggestPage.setFilterFixedString(text)
1129        self.__pages.setCurrentPage(self.__suggestPage)
1130
1131    def triggerSearch(self):
1132        """
1133        Trigger action search. This changes to current page to the
1134        'Suggest' page and sets the keyboard focus to the search line edit.
1135
1136        """
1137        self.__pages.setCurrentPage(self.__suggestPage)
1138        self.__search.setFocus(Qt.ShortcutFocusReason)
1139
1140        # Make sure that the first enabled item is set current.
1141        self.__suggestPage.ensureCurrent()
1142
1143    def keyPressEvent(self, event):
1144        if event.text():
1145            # Ignore modifiers, ...
1146            self.__search.setFocus(Qt.ShortcutFocusReason)
1147            self.setCurrentIndex(0)
1148            self.__search.keyPressEvent(event)
1149
1150        FramelessWindow.keyPressEvent(self, event)
1151        event.accept()
1152
1153    def event(self, event):
1154        if event.type() == QEvent.ShortcutOverride:
1155            log.debug("Overriding shortcuts")
1156            event.accept()
1157            return True
1158        return FramelessWindow.event(self, event)
1159
1160    def eventFilter(self, obj, event):
1161        if isinstance(obj, QTreeView):
1162            etype = event.type()
1163            if etype == QEvent.KeyPress:
1164                # ignore modifiers non printable characters, Enter, ...
1165                if event.text() and event.key() not in \
1166                        [Qt.Key_Enter, Qt.Key_Return]:
1167                    self.__search.setFocus(Qt.ShortcutFocusReason)
1168                    self.setCurrentIndex(0)
1169                    self.__search.keyPressEvent(event)
1170                    return True
1171
1172        return FramelessWindow.eventFilter(self, obj, event)
1173
1174
1175class ItemViewKeyNavigator(QObject):
1176    """
1177    A event filter class listening to key press events and responding
1178    by moving 'currentItem` on a :class:`QListView`.
1179
1180    """
1181    def __init__(self, parent=None):
1182        QObject.__init__(self, parent)
1183        self.__view = None
1184
1185    def setView(self, view):
1186        """
1187        Set the QListView.
1188        """
1189        if self.__view != view:
1190            self.__view = view
1191
1192    def view(self):
1193        """
1194        Return the view
1195        """
1196        return self.__view
1197
1198    def eventFilter(self, obj, event):
1199        etype = event.type()
1200        if etype == QEvent.KeyPress:
1201            key = event.key()
1202            if key == Qt.Key_Down:
1203                self.moveCurrent(1, 0)
1204                return True
1205            elif key == Qt.Key_Up:
1206                self.moveCurrent(-1, 0)
1207                return True
1208            elif key == Qt.Key_Tab:
1209                self.moveCurrent(0, 1)
1210                return  True
1211            elif key == Qt.Key_Enter or key == Qt.Key_Return:
1212                self.activateCurrent()
1213                return True
1214
1215        return QObject.eventFilter(self, obj, event)
1216
1217    def moveCurrent(self, rows, columns=0):
1218        """
1219        Move the current index by rows, columns.
1220        """
1221        if self.__view is not None:
1222            view = self.__view
1223            model = view.model()
1224
1225            curr = view.currentIndex()
1226            curr_row, curr_col = curr.row(), curr.column()
1227
1228            sign = 1 if rows >= 0 else -1
1229            row = curr_row + rows
1230
1231            row_count = model.rowCount()
1232            for i in range(row_count):
1233                index = model.index((row + sign * i) % row_count, 0)
1234                if index.flags() & Qt.ItemIsEnabled:
1235                    view.setCurrentIndex(index)
1236                    break
1237            # TODO: move by columns
1238
1239    def activateCurrent(self):
1240        """
1241        Activate the current index.
1242        """
1243        if self.__view is not None:
1244            curr = self.__view.currentIndex()
1245            if curr.isValid():
1246                # TODO: Does this work? We are emitting signals that are
1247                # defined by a different class. This might break some things.
1248                # Should we just send the keyPress events to the view, and let
1249                # it handle them.
1250                self.__view.activated.emit(curr)
1251
1252    def ensureCurrent(self):
1253        """
1254        Ensure the view has a current item if one is available.
1255        """
1256        if self.__view is not None:
1257            model = self.__view.model()
1258            curr = self.__view.currentIndex()
1259            if not curr.isValid():
1260                for i in range(model.rowCount()):
1261                    index = model.index(i, 0)
1262                    if index.flags() & Qt.ItemIsEnabled:
1263                        self.__view.setCurrentIndex(index)
1264                        break
1265
1266
1267class WindowSizeGrip(QSizeGrip):
1268    """
1269    Automatically positioning :class:`QSizeGrip`.
1270    The widget automatically maintains its position in the window
1271    corner during resize events.
1272
1273    """
1274    def __init__(self, parent):
1275        QSizeGrip.__init__(self, parent)
1276        self.__corner = Qt.BottomRightCorner
1277
1278        self.resize(self.sizeHint())
1279
1280        self.__updatePos()
1281
1282    def setCorner(self, corner):
1283        """
1284        Set the corner (:class:`Qt.Corner`) where the size grip should
1285        position itself.
1286
1287        """
1288        if corner not in [Qt.TopLeftCorner, Qt.TopRightCorner,
1289                          Qt.BottomLeftCorner, Qt.BottomRightCorner]:
1290            raise ValueError("Qt.Corner flag expected")
1291
1292        if self.__corner != corner:
1293            self.__corner = corner
1294            self.__updatePos()
1295
1296    def corner(self):
1297        """
1298        Return the corner where the size grip is positioned.
1299        """
1300        return self.__corner
1301
1302    def eventFilter(self, obj, event):
1303        if obj is self.window():
1304            if event.type() == QEvent.Resize:
1305                self.__updatePos()
1306
1307        return QSizeGrip.eventFilter(self, obj, event)
1308
1309    def showEvent(self, event):
1310        if self.window() != self.parent():
1311            log.error("%s: Can only show on a top level window.",
1312                      type(self).__name__)
1313
1314        return QSizeGrip.showEvent(self, event)
1315
1316    def __updatePos(self):
1317        window = self.window()
1318
1319        if window is not self.parent():
1320            return
1321
1322        corner = self.__corner
1323        size = self.sizeHint()
1324
1325        window_geom = window.geometry()
1326        window_size = window_geom.size()
1327
1328        if corner in [Qt.TopLeftCorner, Qt.BottomLeftCorner]:
1329            x = 0
1330        else:
1331            x = window_geom.width() - size.width()
1332
1333        if corner in [Qt.TopLeftCorner, Qt.TopRightCorner]:
1334            y = 0
1335        else:
1336            y = window_size.height() - size.height()
1337
1338        self.move(x, y)
Note: See TracBrowser for help on using the repository browser.