source: orange/Orange/OrangeCanvas/document/quickmenu.py @ 11501:dd44b3a10e49

Revision 11501:dd44b3a10e49, 38.4 KB checked in by Ales Erjavec <ales.erjavec@…>, 12 months ago (diff)

Changed MenuPage size hint computation.

No longer uses the default QTreeView size hint as a minimum.

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(), 0)
625        p4 = self.pos() + QPoint(self.width(), 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        self.__sloppyButton = button
635        delay = self.style().styleHint(QStyle.SH_Menu_SubMenuPopupDelay, None)
636        # The delay timeout is the same as used by Qt in the QMenu.
637        self.__sloppyTimer.start(delay * 6)
638
639    def __onSloppyTimeout(self):
640        if self.__sloppyButton is not None:
641            button = self.__sloppyButton
642            self.__sloppyButton = None
643            if not button.isChecked():
644                index = [tab.button for tab in self.__tabs].index(button)
645                self.setCurrentIndex(index)
646
647                # Update the sloppy region from the current cursor position.
648                current = self.mapFromGlobal(QCursor.pos())
649                if self.contentsRect().contains(current):
650                    self.__sloppyRegion = self.__calcSloppyRegion(current)
651
652    def eventFilter(self, receiver, event):
653        if event.type() == QEvent.MouseMove and \
654                isinstance(receiver, TabButton):
655            pos = receiver.mapTo(self, event.pos())
656            if self.__sloppyRegion.contains(pos):
657                self.__setSloppyButton(receiver)
658            else:
659                if not receiver.isChecked():
660                    index = [tab.button for tab in self.__tabs].index(receiver)
661                    self.setCurrentIndex(index)
662                    self.__sloppyRegion = self.__calcSloppyRegion(pos)
663
664        return QWidget.eventFilter(self, receiver, event)
665
666    def leaveEvent(self, event):
667        self.__sloppyButton = None
668        self.__sloppyRegion = QRegion()
669
670        return QWidget.leaveEvent(self, event)
671
672
673class PagedMenu(QWidget):
674    """
675    Tabbed container for :class:`MenuPage` instances.
676    """
677    triggered = Signal(QAction)
678    hovered = Signal(QAction)
679
680    currentChanged = Signal(int)
681
682    def __init__(self, parent=None, **kwargs):
683        QWidget.__init__(self, parent, **kwargs)
684
685        self.__pages = []
686        self.__currentIndex = -1
687
688        layout = QHBoxLayout()
689        layout.setContentsMargins(0, 0, 0, 0)
690        layout.setSpacing(6)
691
692        self.__tab = TabBarWidget(self)
693        self.__tab.currentChanged.connect(self.setCurrentIndex)
694        self.__tab.setChangeOnHover(True)
695
696        self.__stack = MenuStackWidget(self)
697
698        layout.addWidget(self.__tab, alignment=Qt.AlignTop)
699        layout.addWidget(self.__stack)
700
701        self.setLayout(layout)
702
703    def addPage(self, page, title, icon=None, toolTip=None):
704        """
705        Add a `page` to the menu and return its index.
706        """
707        return self.insertPage(self.count(), page, title, icon, toolTip)
708
709    def insertPage(self, index, page, title, icon=None, toolTip=None):
710        """
711        Insert `page` at `index`.
712        """
713        page.triggered.connect(self.triggered)
714        page.hovered.connect(self.hovered)
715
716        self.__stack.insertWidget(index, page)
717        self.__tab.insertTab(index, title, icon, toolTip)
718        return index
719
720    def page(self, index):
721        """
722        Return the page at index.
723        """
724        return self.__stack.widget(index)
725
726    def removePage(self, index):
727        """
728        Remove the page at `index`.
729        """
730        page = self.__stack.widget(index)
731        page.triggered.disconnect(self.triggered)
732        page.hovered.disconnect(self.hovered)
733
734        self.__stack.removeWidget(page)
735        self.__tab.removeTab(index)
736
737    def count(self):
738        """
739        Return the number of pages.
740        """
741        return self.__stack.count()
742
743    def setCurrentIndex(self, index):
744        """
745        Set the current page index.
746        """
747        if self.__currentIndex != index:
748            self.__currentIndex = index
749            self.__tab.setCurrentIndex(index)
750            self.__stack.setCurrentIndex(index)
751            self.currentChanged.emit(index)
752
753    def currentIndex(self):
754        """
755        Return the index of the current page.
756        """
757        return self.__currentIndex
758
759    def setCurrentPage(self, page):
760        """
761        Set `page` to be the current shown page.
762        """
763        index = self.__stack.indexOf(page)
764        self.setCurrentIndex(index)
765
766    def currentPage(self):
767        """
768        Return the current page.
769        """
770        return self.__stack.currentWidget()
771
772    def indexOf(self, page):
773        """
774        Return the index of `page`.
775        """
776        return self.__stack.indexOf(page)
777
778    def tabButton(self, index):
779        """
780        Return the tab button instance for index.
781        """
782        return self.__tab.button(index)
783
784
785TAB_BUTTON_STYLE_TEMPLATE = """\
786TabButton {
787    qproperty-flat_: false;
788    background: %s;
789    border: none;
790    border-bottom: 1px solid palette(dark);
791}
792
793TabButton:checked {
794    background: %s;
795    border: none;
796    border-top: 1px solid #609ED7;
797    border-bottom: 1px solid #609ED7;
798}
799"""
800
801
802class QuickMenu(FramelessWindow):
803    """
804    A quick menu popup for the widgets.
805
806    The widgets are set using :func:`QuickMenu.setModel` which must be a
807    model as returned by :func:`QtWidgetRegistry.model`
808
809    """
810
811    #: An action has been triggered in the menu.
812    triggered = Signal(QAction)
813
814    #: An action has been hovered in the menu
815    hovered = Signal(QAction)
816
817    def __init__(self, parent=None, **kwargs):
818        FramelessWindow.__init__(self, parent, **kwargs)
819        self.setWindowFlags(Qt.Popup)
820
821        self.__filterFunc = None
822
823        self.__setupUi()
824
825        self.__loop = None
826        self.__model = QStandardItemModel()
827        self.__triggeredAction = None
828
829    def __setupUi(self):
830        self.setLayout(QVBoxLayout(self))
831        self.layout().setContentsMargins(6, 6, 6, 6)
832
833        self.__search = SearchWidget(self, objectName="search-line")
834
835        self.__search.setPlaceholderText(
836            self.tr("Search for widget or select from the list.")
837        )
838
839        self.layout().addWidget(self.__search)
840
841        self.__frame = QFrame(self, objectName="menu-frame")
842        layout = QVBoxLayout()
843        layout.setContentsMargins(0, 0, 0, 0)
844        layout.setSpacing(2)
845        self.__frame.setLayout(layout)
846
847        self.layout().addWidget(self.__frame)
848
849        self.__pages = PagedMenu(self, objectName="paged-menu")
850        self.__pages.currentChanged.connect(self.setCurrentIndex)
851        self.__pages.triggered.connect(self.triggered)
852        self.__pages.hovered.connect(self.hovered)
853
854        self.__frame.layout().addWidget(self.__pages)
855
856        self.setSizePolicy(QSizePolicy.Fixed,
857                           QSizePolicy.Expanding)
858
859        self.__suggestPage = SuggestMenuPage(self, objectName="suggest-page")
860        self.__suggestPage.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE)
861        self.__suggestPage.setIcon(icon_loader().get("icons/Search.svg"))
862
863        if sys.platform == "darwin":
864            view = self.__suggestPage.view()
865            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
866            # Don't show the focus frame because it expands into the tab bar.
867            view.setAttribute(Qt.WA_MacShowFocusRect, False)
868
869        i = self.addPage(self.tr("Quick Search"), self.__suggestPage)
870        button = self.__pages.tabButton(i)
871        button.setObjectName("search-tab-button")
872        button.setStyleSheet(
873            "TabButton {\n"
874            "    qproperty-flat_: false;\n"
875            "    border: none;"
876            "}\n")
877
878        self.__search.textEdited.connect(self.__on_textEdited)
879
880        self.__navigator = ItemViewKeyNavigator(self)
881        self.__navigator.setView(self.__suggestPage.view())
882        self.__search.installEventFilter(self.__navigator)
883
884        self.__grip = WindowSizeGrip(self)
885        self.__grip.raise_()
886
887    def setSizeGripEnabled(self, enabled):
888        """
889        Enable the resizing of the menu with a size grip in a bottom
890        right corner (enabled by default).
891
892        """
893        if bool(enabled) != bool(self.__grip):
894            if self.__grip:
895                self.__grip.deleteLater()
896                self.__grip = None
897            else:
898                self.__grip = WindowSizeGrip(self)
899                self.__grip.raise_()
900
901    def sizeGripEnabled(self):
902        """
903        Is the size grip enabled.
904        """
905        return bool(self.__grip)
906
907    def addPage(self, name, page):
908        """
909        Add the `page` (:class:`MenuPage`) with `name` and return it's index.
910        The `page.icon()` will be used as the icon in the tab bar.
911
912        """
913        icon = page.icon()
914
915        tip = name
916        if page.toolTip():
917            tip = page.toolTip()
918
919        index = self.__pages.addPage(page, name, icon, tip)
920
921        # Route the page's signals
922        page.triggered.connect(self.__onTriggered)
923        page.hovered.connect(self.hovered)
924
925        # Install event filter to intercept key presses.
926        page.view().installEventFilter(self)
927
928        return index
929
930    def createPage(self, index):
931        """
932        Create a new page based on the contents of an index
933        (:class:`QModeIndex`) item.
934
935        """
936        page = MenuPage(self)
937
938        page.setModel(index.model())
939        page.setRootIndex(index)
940
941        view = page.view()
942
943        if sys.platform == "darwin":
944            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
945            # Don't show the focus frame because it expands into the tab
946            # bar at the top.
947            view.setAttribute(Qt.WA_MacShowFocusRect, False)
948
949        name = unicode(index.data(Qt.DisplayRole))
950        page.setTitle(name)
951
952        icon = index.data(Qt.DecorationRole).toPyObject()
953        if isinstance(icon, QIcon):
954            page.setIcon(icon)
955
956        page.setToolTip(index.data(Qt.ToolTipRole).toPyObject())
957        return page
958
959    def setModel(self, model):
960        """
961        Set the model containing the actions.
962        """
963        root = model.invisibleRootItem()
964        for i in range(root.rowCount()):
965            item = root.child(i)
966            index = item.index()
967            page = self.createPage(index)
968            page.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE)
969            i = self.addPage(page.title(), page)
970
971            brush = index.data(QtWidgetRegistry.BACKGROUND_ROLE)
972
973            if brush.isValid():
974                brush = brush.toPyObject()
975                base_color = brush.color()
976                button = self.__pages.tabButton(i)
977                button.setStyleSheet(
978                    TAB_BUTTON_STYLE_TEMPLATE %
979                    (create_css_gradient(base_color),
980                     create_css_gradient(base_color.darker(120)))
981                )
982
983        self.__model = model
984        self.__suggestPage.setModel(model)
985
986    def setFilterFunc(self, func):
987        """
988        Set a filter function.
989        """
990        if func != self.__filterFunc:
991            self.__filterFunc = func
992            for i in range(0, self.__pages.count()):
993                self.__pages.page(i).setFilterFunc(func)
994
995    def popup(self, pos=None, searchText=""):
996        """
997        Popup the menu at `pos` (in screen coordinates). 'Search' text field
998        is initialized with `searchText` if provided.
999        """
1000        if pos is None:
1001            pos = QPoint()
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 __onTriggered(self, action):
1093        """
1094        Re-emit the action from the page.
1095        """
1096        self.__triggeredAction = action
1097
1098        # Hide and exit the event loop if necessary.
1099        self.hide()
1100        self.triggered.emit(action)
1101
1102    def __on_textEdited(self, text):
1103        self.__suggestPage.setFilterFixedString(text)
1104        self.__pages.setCurrentPage(self.__suggestPage)
1105
1106    def triggerSearch(self):
1107        """
1108        Trigger action search. This changes to current page to the
1109        'Suggest' page and sets the keyboard focus to the search line edit.
1110
1111        """
1112        self.__pages.setCurrentPage(self.__suggestPage)
1113        self.__search.setFocus(Qt.ShortcutFocusReason)
1114
1115        # Make sure that the first enabled item is set current.
1116        self.__suggestPage.ensureCurrent()
1117
1118    def keyPressEvent(self, event):
1119        if event.text():
1120            # Ignore modifiers, ...
1121            self.__search.setFocus(Qt.ShortcutFocusReason)
1122            self.setCurrentIndex(0)
1123            self.__search.keyPressEvent(event)
1124
1125        FramelessWindow.keyPressEvent(self, event)
1126        event.accept()
1127
1128    def event(self, event):
1129        if event.type() == QEvent.ShortcutOverride:
1130            log.debug("Overriding shortcuts")
1131            event.accept()
1132            return True
1133        return FramelessWindow.event(self, event)
1134
1135    def eventFilter(self, obj, event):
1136        if isinstance(obj, QTreeView):
1137            etype = event.type()
1138            if etype == QEvent.KeyPress:
1139                # ignore modifiers non printable characters, Enter, ...
1140                if event.text() and event.key() not in \
1141                        [Qt.Key_Enter, Qt.Key_Return]:
1142                    self.__search.setFocus(Qt.ShortcutFocusReason)
1143                    self.setCurrentIndex(0)
1144                    self.__search.keyPressEvent(event)
1145                    return True
1146
1147        return FramelessWindow.eventFilter(self, obj, event)
1148
1149
1150class ItemViewKeyNavigator(QObject):
1151    """
1152    A event filter class listening to key press events and responding
1153    by moving 'currentItem` on a :class:`QListView`.
1154
1155    """
1156    def __init__(self, parent=None):
1157        QObject.__init__(self, parent)
1158        self.__view = None
1159
1160    def setView(self, view):
1161        """
1162        Set the QListView.
1163        """
1164        if self.__view != view:
1165            self.__view = view
1166
1167    def view(self):
1168        """
1169        Return the view
1170        """
1171        return self.__view
1172
1173    def eventFilter(self, obj, event):
1174        etype = event.type()
1175        if etype == QEvent.KeyPress:
1176            key = event.key()
1177            if key == Qt.Key_Down:
1178                self.moveCurrent(1, 0)
1179                return True
1180            elif key == Qt.Key_Up:
1181                self.moveCurrent(-1, 0)
1182                return True
1183            elif key == Qt.Key_Tab:
1184                self.moveCurrent(0, 1)
1185                return  True
1186            elif key == Qt.Key_Enter or key == Qt.Key_Return:
1187                self.activateCurrent()
1188                return True
1189
1190        return QObject.eventFilter(self, obj, event)
1191
1192    def moveCurrent(self, rows, columns=0):
1193        """
1194        Move the current index by rows, columns.
1195        """
1196        if self.__view is not None:
1197            view = self.__view
1198            model = view.model()
1199
1200            curr = view.currentIndex()
1201            curr_row, curr_col = curr.row(), curr.column()
1202
1203            sign = 1 if rows >= 0 else -1
1204            row = curr_row + rows
1205
1206            row_count = model.rowCount()
1207            for i in range(row_count):
1208                index = model.index((row + sign * i) % row_count, 0)
1209                if index.flags() & Qt.ItemIsEnabled:
1210                    view.setCurrentIndex(index)
1211                    break
1212            # TODO: move by columns
1213
1214    def activateCurrent(self):
1215        """
1216        Activate the current index.
1217        """
1218        if self.__view is not None:
1219            curr = self.__view.currentIndex()
1220            if curr.isValid():
1221                # TODO: Does this work? We are emitting signals that are
1222                # defined by a different class. This might break some things.
1223                # Should we just send the keyPress events to the view, and let
1224                # it handle them.
1225                self.__view.activated.emit(curr)
1226
1227    def ensureCurrent(self):
1228        """
1229        Ensure the view has a current item if one is available.
1230        """
1231        if self.__view is not None:
1232            model = self.__view.model()
1233            curr = self.__view.currentIndex()
1234            if not curr.isValid():
1235                for i in range(model.rowCount()):
1236                    index = model.index(i, 0)
1237                    if index.flags() & Qt.ItemIsEnabled:
1238                        self.__view.setCurrentIndex(index)
1239                        break
1240
1241
1242class WindowSizeGrip(QSizeGrip):
1243    """
1244    Automatically positioning :class:`QSizeGrip`.
1245    The widget automatically maintains its position in the window
1246    corner during resize events.
1247
1248    """
1249    def __init__(self, parent):
1250        QSizeGrip.__init__(self, parent)
1251        self.__corner = Qt.BottomRightCorner
1252
1253        self.resize(self.sizeHint())
1254
1255        self.__updatePos()
1256
1257    def setCorner(self, corner):
1258        """
1259        Set the corner (:class:`Qt.Corner`) where the size grip should
1260        position itself.
1261
1262        """
1263        if corner not in [Qt.TopLeftCorner, Qt.TopRightCorner,
1264                          Qt.BottomLeftCorner, Qt.BottomRightCorner]:
1265            raise ValueError("Qt.Corner flag expected")
1266
1267        if self.__corner != corner:
1268            self.__corner = corner
1269            self.__updatePos()
1270
1271    def corner(self):
1272        """
1273        Return the corner where the size grip is positioned.
1274        """
1275        return self.__corner
1276
1277    def eventFilter(self, obj, event):
1278        if obj is self.window():
1279            if event.type() == QEvent.Resize:
1280                self.__updatePos()
1281
1282        return QSizeGrip.eventFilter(self, obj, event)
1283
1284    def showEvent(self, event):
1285        if self.window() != self.parent():
1286            log.error("%s: Can only show on a top level window.",
1287                      type(self).__name__)
1288
1289        return QSizeGrip.showEvent(self, event)
1290
1291    def __updatePos(self):
1292        window = self.window()
1293
1294        if window is not self.parent():
1295            return
1296
1297        corner = self.__corner
1298        size = self.sizeHint()
1299
1300        window_geom = window.geometry()
1301        window_size = window_geom.size()
1302
1303        if corner in [Qt.TopLeftCorner, Qt.BottomLeftCorner]:
1304            x = 0
1305        else:
1306            x = window_geom.width() - size.width()
1307
1308        if corner in [Qt.TopLeftCorner, Qt.TopRightCorner]:
1309            y = 0
1310        else:
1311            y = window_size.height() - size.height()
1312
1313        self.move(x, y)
1314
1315
1316def create_css_gradient(base_color):
1317    """
1318    Create a Qt css linear gradient fragment based on the `base_color`.
1319    """
1320    grad = create_tab_gradient(base_color)
1321    stops = grad.stops()
1322    stops = "\n".join("    stop: {0:f} {1}".format(stop, color.name())
1323                      for stop, color in stops)
1324    return ("qlineargradient(\n"
1325            "    x1: 0, y1: 0, x2: 0, y2: 1,\n"
1326            "{0})").format(stops)
Note: See TracBrowser for help on using the repository browser.