source: orange/Orange/OrangeCanvas/document/quickmenu.py @ 11507:4b7fba2a56bc

Revision 11507:4b7fba2a56bc, 38.6 KB checked in by Ales Erjavec <ales.erjavec@…>, 11 months ago (diff)

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