source: orange/Orange/OrangeCanvas/document/quickmenu.py @ 11526:5d54a8455087

Revision 11526:5d54a8455087, 38.8 KB checked in by markotoplak, 11 months ago (diff)

quickmenu: added border on the right of category buttons

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    border-right: 1px solid palette(mid);
807}
808
809TabButton:checked {
810    background: %s;
811    border: none;
812    border-top: 1px solid #609ED7;
813    border-bottom: 1px solid #609ED7;
814    border-right: 1px solid #609ED7;
815}
816"""
817
818
819class QuickMenu(FramelessWindow):
820    """
821    A quick menu popup for the widgets.
822
823    The widgets are set using :func:`QuickMenu.setModel` which must be a
824    model as returned by :func:`QtWidgetRegistry.model`
825
826    """
827
828    #: An action has been triggered in the menu.
829    triggered = Signal(QAction)
830
831    #: An action has been hovered in the menu
832    hovered = Signal(QAction)
833
834    def __init__(self, parent=None, **kwargs):
835        FramelessWindow.__init__(self, parent, **kwargs)
836        self.setWindowFlags(Qt.Popup)
837
838        self.__filterFunc = None
839
840        self.__setupUi()
841
842        self.__loop = None
843        self.__model = QStandardItemModel()
844        self.__triggeredAction = None
845
846    def __setupUi(self):
847        self.setLayout(QVBoxLayout(self))
848        self.layout().setContentsMargins(6, 6, 6, 6)
849
850        self.__search = SearchWidget(self, objectName="search-line")
851
852        self.__search.setPlaceholderText(
853            self.tr("Search for widget or select from the list.")
854        )
855
856        self.layout().addWidget(self.__search)
857
858        self.__frame = QFrame(self, objectName="menu-frame")
859        layout = QVBoxLayout()
860        layout.setContentsMargins(0, 0, 0, 0)
861        layout.setSpacing(2)
862        self.__frame.setLayout(layout)
863
864        self.layout().addWidget(self.__frame)
865
866        self.__pages = PagedMenu(self, objectName="paged-menu")
867        self.__pages.currentChanged.connect(self.setCurrentIndex)
868        self.__pages.triggered.connect(self.triggered)
869        self.__pages.hovered.connect(self.hovered)
870
871        self.__frame.layout().addWidget(self.__pages)
872
873        self.setSizePolicy(QSizePolicy.Fixed,
874                           QSizePolicy.Expanding)
875
876        self.__suggestPage = SuggestMenuPage(self, objectName="suggest-page")
877        self.__suggestPage.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE)
878        self.__suggestPage.setIcon(icon_loader().get("icons/Search.svg"))
879
880        if sys.platform == "darwin":
881            view = self.__suggestPage.view()
882            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
883            # Don't show the focus frame because it expands into the tab bar.
884            view.setAttribute(Qt.WA_MacShowFocusRect, False)
885
886        i = self.addPage(self.tr("Quick Search"), self.__suggestPage)
887        button = self.__pages.tabButton(i)
888        button.setObjectName("search-tab-button")
889        button.setStyleSheet(
890            "TabButton {\n"
891            "    qproperty-flat_: false;\n"
892            "    border: none;"
893            "}\n")
894
895        self.__search.textEdited.connect(self.__on_textEdited)
896
897        self.__navigator = ItemViewKeyNavigator(self)
898        self.__navigator.setView(self.__suggestPage.view())
899        self.__search.installEventFilter(self.__navigator)
900
901        self.__grip = WindowSizeGrip(self)
902        self.__grip.raise_()
903
904    def setSizeGripEnabled(self, enabled):
905        """
906        Enable the resizing of the menu with a size grip in a bottom
907        right corner (enabled by default).
908
909        """
910        if bool(enabled) != bool(self.__grip):
911            if self.__grip:
912                self.__grip.deleteLater()
913                self.__grip = None
914            else:
915                self.__grip = WindowSizeGrip(self)
916                self.__grip.raise_()
917
918    def sizeGripEnabled(self):
919        """
920        Is the size grip enabled.
921        """
922        return bool(self.__grip)
923
924    def addPage(self, name, page):
925        """
926        Add the `page` (:class:`MenuPage`) with `name` and return it's index.
927        The `page.icon()` will be used as the icon in the tab bar.
928
929        """
930        icon = page.icon()
931
932        tip = name
933        if page.toolTip():
934            tip = page.toolTip()
935
936        index = self.__pages.addPage(page, name, icon, tip)
937
938        # Route the page's signals
939        page.triggered.connect(self.__onTriggered)
940        page.hovered.connect(self.hovered)
941
942        # Install event filter to intercept key presses.
943        page.view().installEventFilter(self)
944
945        return index
946
947    def createPage(self, index):
948        """
949        Create a new page based on the contents of an index
950        (:class:`QModeIndex`) item.
951
952        """
953        page = MenuPage(self)
954
955        page.setModel(index.model())
956        page.setRootIndex(index)
957
958        view = page.view()
959
960        if sys.platform == "darwin":
961            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
962            # Don't show the focus frame because it expands into the tab
963            # bar at the top.
964            view.setAttribute(Qt.WA_MacShowFocusRect, False)
965
966        name = unicode(index.data(Qt.DisplayRole))
967        page.setTitle(name)
968
969        icon = index.data(Qt.DecorationRole).toPyObject()
970        if isinstance(icon, QIcon):
971            page.setIcon(icon)
972
973        page.setToolTip(index.data(Qt.ToolTipRole).toPyObject())
974        return page
975
976    def setModel(self, model):
977        """
978        Set the model containing the actions.
979        """
980        root = model.invisibleRootItem()
981        for i in range(root.rowCount()):
982            item = root.child(i)
983            index = item.index()
984            page = self.createPage(index)
985            page.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE)
986            i = self.addPage(page.title(), page)
987
988            brush = index.data(QtWidgetRegistry.BACKGROUND_ROLE)
989
990            if brush.isValid():
991                brush = brush.toPyObject()
992                base_color = brush.color()
993                button = self.__pages.tabButton(i)
994                button.setStyleSheet(
995                    TAB_BUTTON_STYLE_TEMPLATE %
996                    (create_css_gradient(base_color),
997                     create_css_gradient(base_color.darker(120)))
998                )
999
1000        self.__model = model
1001        self.__suggestPage.setModel(model)
1002
1003    def setFilterFunc(self, func):
1004        """
1005        Set a filter function.
1006        """
1007        if func != self.__filterFunc:
1008            self.__filterFunc = func
1009            for i in range(0, self.__pages.count()):
1010                self.__pages.page(i).setFilterFunc(func)
1011
1012    def popup(self, pos=None, searchText=""):
1013        """
1014        Popup the menu at `pos` (in screen coordinates). 'Search' text field
1015        is initialized with `searchText` if provided.
1016
1017        """
1018        if pos is None:
1019            pos = QPoint()
1020
1021        self.__clearCurrentItems()
1022
1023        self.__search.setText(searchText)
1024        self.__suggestPage.setFilterFixedString(searchText)
1025
1026        self.ensurePolished()
1027
1028        if self.testAttribute(Qt.WA_Resized) and self.sizeGripEnabled():
1029            size = self.size()
1030        else:
1031            size = self.sizeHint()
1032
1033        desktop = QApplication.desktop()
1034        screen_geom = desktop.availableGeometry(pos)
1035
1036        # Adjust the size to fit inside the screen.
1037        if size.height() > screen_geom.height():
1038            size.setHeight(screen_geom.height())
1039        if size.width() > screen_geom.width():
1040            size.setWidth(screen_geom.width())
1041
1042        geom = QRect(pos, size)
1043
1044        if geom.top() < screen_geom.top():
1045            geom.setTop(screen_geom.top())
1046
1047        if geom.left() < screen_geom.left():
1048            geom.setLeft(screen_geom.left())
1049
1050        bottom_margin = screen_geom.bottom() - geom.bottom()
1051        right_margin = screen_geom.right() - geom.right()
1052        if bottom_margin < 0:
1053            # Falls over the bottom of the screen, move it up.
1054            geom.translate(0, bottom_margin)
1055
1056        # TODO: right to left locale
1057        if right_margin < 0:
1058            # Falls over the right screen edge, move the menu to the
1059            # other side of pos.
1060            geom.translate(-size.width(), 0)
1061
1062        self.setGeometry(geom)
1063
1064        self.show()
1065
1066        if searchText:
1067            self.setFocusProxy(self.__search)
1068        else:
1069            self.setFocusProxy(None)
1070
1071    def exec_(self, pos=None, searchText=""):
1072        """
1073        Execute the menu at position `pos` (in global screen coordinates).
1074        Return the triggered :class:`QAction` or `None` if no action was
1075        triggered. 'Search' text field is initialized with `searchText` if
1076        provided.
1077
1078        """
1079        self.popup(pos, searchText)
1080        self.setFocus(Qt.PopupFocusReason)
1081
1082        self.__triggeredAction = None
1083        self.__loop = QEventLoop()
1084        self.__loop.exec_()
1085        self.__loop.deleteLater()
1086        self.__loop = None
1087
1088        action = self.__triggeredAction
1089        self.__triggeredAction = None
1090        return action
1091
1092    def hideEvent(self, event):
1093        """
1094        Reimplemented from :class:`QWidget`
1095        """
1096        FramelessWindow.hideEvent(self, event)
1097        if self.__loop:
1098            self.__loop.exit()
1099
1100    def setCurrentPage(self, page):
1101        """
1102        Set the current shown page to `page`.
1103        """
1104        self.__pages.setCurrentPage(page)
1105
1106    def setCurrentIndex(self, index):
1107        """
1108        Set the current page index.
1109        """
1110        self.__pages.setCurrentIndex(index)
1111
1112    def __clearCurrentItems(self):
1113        """
1114        Clear any selected (or current) items in all the menus.
1115        """
1116        for i in range(self.__pages.count()):
1117            self.__pages.page(i).view().selectionModel().clear()
1118
1119    def __onTriggered(self, action):
1120        """
1121        Re-emit the action from the page.
1122        """
1123        self.__triggeredAction = action
1124
1125        # Hide and exit the event loop if necessary.
1126        self.hide()
1127        self.triggered.emit(action)
1128
1129    def __on_textEdited(self, text):
1130        self.__suggestPage.setFilterFixedString(text)
1131        self.__pages.setCurrentPage(self.__suggestPage)
1132
1133    def triggerSearch(self):
1134        """
1135        Trigger action search. This changes to current page to the
1136        'Suggest' page and sets the keyboard focus to the search line edit.
1137
1138        """
1139        self.__pages.setCurrentPage(self.__suggestPage)
1140        self.__search.setFocus(Qt.ShortcutFocusReason)
1141
1142        # Make sure that the first enabled item is set current.
1143        self.__suggestPage.ensureCurrent()
1144
1145    def keyPressEvent(self, event):
1146        if event.text():
1147            # Ignore modifiers, ...
1148            self.__search.setFocus(Qt.ShortcutFocusReason)
1149            self.setCurrentIndex(0)
1150            self.__search.keyPressEvent(event)
1151
1152        FramelessWindow.keyPressEvent(self, event)
1153        event.accept()
1154
1155    def event(self, event):
1156        if event.type() == QEvent.ShortcutOverride:
1157            log.debug("Overriding shortcuts")
1158            event.accept()
1159            return True
1160        return FramelessWindow.event(self, event)
1161
1162    def eventFilter(self, obj, event):
1163        if isinstance(obj, QTreeView):
1164            etype = event.type()
1165            if etype == QEvent.KeyPress:
1166                # ignore modifiers non printable characters, Enter, ...
1167                if event.text() and event.key() not in \
1168                        [Qt.Key_Enter, Qt.Key_Return]:
1169                    self.__search.setFocus(Qt.ShortcutFocusReason)
1170                    self.setCurrentIndex(0)
1171                    self.__search.keyPressEvent(event)
1172                    return True
1173
1174        return FramelessWindow.eventFilter(self, obj, event)
1175
1176
1177class ItemViewKeyNavigator(QObject):
1178    """
1179    A event filter class listening to key press events and responding
1180    by moving 'currentItem` on a :class:`QListView`.
1181
1182    """
1183    def __init__(self, parent=None):
1184        QObject.__init__(self, parent)
1185        self.__view = None
1186
1187    def setView(self, view):
1188        """
1189        Set the QListView.
1190        """
1191        if self.__view != view:
1192            self.__view = view
1193
1194    def view(self):
1195        """
1196        Return the view
1197        """
1198        return self.__view
1199
1200    def eventFilter(self, obj, event):
1201        etype = event.type()
1202        if etype == QEvent.KeyPress:
1203            key = event.key()
1204            if key == Qt.Key_Down:
1205                self.moveCurrent(1, 0)
1206                return True
1207            elif key == Qt.Key_Up:
1208                self.moveCurrent(-1, 0)
1209                return True
1210            elif key == Qt.Key_Tab:
1211                self.moveCurrent(0, 1)
1212                return  True
1213            elif key == Qt.Key_Enter or key == Qt.Key_Return:
1214                self.activateCurrent()
1215                return True
1216
1217        return QObject.eventFilter(self, obj, event)
1218
1219    def moveCurrent(self, rows, columns=0):
1220        """
1221        Move the current index by rows, columns.
1222        """
1223        if self.__view is not None:
1224            view = self.__view
1225            model = view.model()
1226
1227            curr = view.currentIndex()
1228            curr_row, curr_col = curr.row(), curr.column()
1229
1230            sign = 1 if rows >= 0 else -1
1231            row = curr_row + rows
1232
1233            row_count = model.rowCount()
1234            for i in range(row_count):
1235                index = model.index((row + sign * i) % row_count, 0)
1236                if index.flags() & Qt.ItemIsEnabled:
1237                    view.setCurrentIndex(index)
1238                    break
1239            # TODO: move by columns
1240
1241    def activateCurrent(self):
1242        """
1243        Activate the current index.
1244        """
1245        if self.__view is not None:
1246            curr = self.__view.currentIndex()
1247            if curr.isValid():
1248                # TODO: Does this work? We are emitting signals that are
1249                # defined by a different class. This might break some things.
1250                # Should we just send the keyPress events to the view, and let
1251                # it handle them.
1252                self.__view.activated.emit(curr)
1253
1254    def ensureCurrent(self):
1255        """
1256        Ensure the view has a current item if one is available.
1257        """
1258        if self.__view is not None:
1259            model = self.__view.model()
1260            curr = self.__view.currentIndex()
1261            if not curr.isValid():
1262                for i in range(model.rowCount()):
1263                    index = model.index(i, 0)
1264                    if index.flags() & Qt.ItemIsEnabled:
1265                        self.__view.setCurrentIndex(index)
1266                        break
1267
1268
1269class WindowSizeGrip(QSizeGrip):
1270    """
1271    Automatically positioning :class:`QSizeGrip`.
1272    The widget automatically maintains its position in the window
1273    corner during resize events.
1274
1275    """
1276    def __init__(self, parent):
1277        QSizeGrip.__init__(self, parent)
1278        self.__corner = Qt.BottomRightCorner
1279
1280        self.resize(self.sizeHint())
1281
1282        self.__updatePos()
1283
1284    def setCorner(self, corner):
1285        """
1286        Set the corner (:class:`Qt.Corner`) where the size grip should
1287        position itself.
1288
1289        """
1290        if corner not in [Qt.TopLeftCorner, Qt.TopRightCorner,
1291                          Qt.BottomLeftCorner, Qt.BottomRightCorner]:
1292            raise ValueError("Qt.Corner flag expected")
1293
1294        if self.__corner != corner:
1295            self.__corner = corner
1296            self.__updatePos()
1297
1298    def corner(self):
1299        """
1300        Return the corner where the size grip is positioned.
1301        """
1302        return self.__corner
1303
1304    def eventFilter(self, obj, event):
1305        if obj is self.window():
1306            if event.type() == QEvent.Resize:
1307                self.__updatePos()
1308
1309        return QSizeGrip.eventFilter(self, obj, event)
1310
1311    def showEvent(self, event):
1312        if self.window() != self.parent():
1313            log.error("%s: Can only show on a top level window.",
1314                      type(self).__name__)
1315
1316        return QSizeGrip.showEvent(self, event)
1317
1318    def __updatePos(self):
1319        window = self.window()
1320
1321        if window is not self.parent():
1322            return
1323
1324        corner = self.__corner
1325        size = self.sizeHint()
1326
1327        window_geom = window.geometry()
1328        window_size = window_geom.size()
1329
1330        if corner in [Qt.TopLeftCorner, Qt.BottomLeftCorner]:
1331            x = 0
1332        else:
1333            x = window_geom.width() - size.width()
1334
1335        if corner in [Qt.TopLeftCorner, Qt.TopRightCorner]:
1336            y = 0
1337        else:
1338            y = window_size.height() - size.height()
1339
1340        self.move(x, y)
Note: See TracBrowser for help on using the repository browser.