source: orange/Orange/OrangeCanvas/document/quickmenu.py @ 11519:ef52206b2845

Revision 11519:ef52206b2845, 38.1 KB checked in by Ales Erjavec <ales.erjavec@…>, 11 months ago (diff)

Slight quick menu style fix.

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