source: orange/Orange/OrangeCanvas/document/quickmenu.py @ 11538:1c55aef55d7f

Revision 11538:1c55aef55d7f, 39.3 KB checked in by Ales Erjavec <ales.erjavec@…>, 11 months ago (diff)

Implemented size hint caching for MenuPage widget.

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