source: orange/Orange/OrangeCanvas/document/quickmenu.py @ 11504:131dec28c5e1

Revision 11504:131dec28c5e1, 38.3 KB checked in by markotoplak, 11 months ago (diff)

quickmenu sloppy regions get updated with mouse moves on the same icon, delay decreased, slight shape changes.

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        if pos is None:
1000            pos = QPoint()
1001
1002        self.__search.setText(searchText)
1003        self.__suggestPage.setFilterFixedString(searchText)
1004
1005        self.ensurePolished()
1006
1007        if self.testAttribute(Qt.WA_Resized) and self.sizeGripEnabled():
1008            size = self.size()
1009        else:
1010            size = self.sizeHint()
1011
1012        desktop = QApplication.desktop()
1013        screen_geom = desktop.availableGeometry(pos)
1014
1015        # Adjust the size to fit inside the screen.
1016        if size.height() > screen_geom.height():
1017            size.setHeight(screen_geom.height())
1018        if size.width() > screen_geom.width():
1019            size.setWidth(screen_geom.width())
1020
1021        geom = QRect(pos, size)
1022
1023        if geom.top() < screen_geom.top():
1024            geom.setTop(screen_geom.top())
1025
1026        if geom.left() < screen_geom.left():
1027            geom.setLeft(screen_geom.left())
1028
1029        bottom_margin = screen_geom.bottom() - geom.bottom()
1030        right_margin = screen_geom.right() - geom.right()
1031        if bottom_margin < 0:
1032            # Falls over the bottom of the screen, move it up.
1033            geom.translate(0, bottom_margin)
1034
1035        # TODO: right to left locale
1036        if right_margin < 0:
1037            # Falls over the right screen edge, move the menu to the
1038            # other side of pos.
1039            geom.translate(-size.width(), 0)
1040
1041        self.setGeometry(geom)
1042
1043        self.show()
1044
1045        if searchText:
1046            self.setFocusProxy(self.__search)
1047        else:
1048            self.setFocusProxy(None)
1049
1050    def exec_(self, pos=None, searchText=""):
1051        """
1052        Execute the menu at position `pos` (in global screen coordinates).
1053        Return the triggered :class:`QAction` or `None` if no action was
1054        triggered. 'Search' text field is initialized with `searchText` if
1055        provided.
1056
1057        """
1058        self.popup(pos, searchText)
1059        self.setFocus(Qt.PopupFocusReason)
1060
1061        self.__triggeredAction = None
1062        self.__loop = QEventLoop()
1063        self.__loop.exec_()
1064        self.__loop.deleteLater()
1065        self.__loop = None
1066
1067        action = self.__triggeredAction
1068        self.__triggeredAction = None
1069        return action
1070
1071    def hideEvent(self, event):
1072        """
1073        Reimplemented from :class:`QWidget`
1074        """
1075        FramelessWindow.hideEvent(self, event)
1076        if self.__loop:
1077            self.__loop.exit()
1078
1079    def setCurrentPage(self, page):
1080        """
1081        Set the current shown page to `page`.
1082        """
1083        self.__pages.setCurrentPage(page)
1084
1085    def setCurrentIndex(self, index):
1086        """
1087        Set the current page index.
1088        """
1089        self.__pages.setCurrentIndex(index)
1090
1091    def __onTriggered(self, action):
1092        """
1093        Re-emit the action from the page.
1094        """
1095        self.__triggeredAction = action
1096
1097        # Hide and exit the event loop if necessary.
1098        self.hide()
1099        self.triggered.emit(action)
1100
1101    def __on_textEdited(self, text):
1102        self.__suggestPage.setFilterFixedString(text)
1103        self.__pages.setCurrentPage(self.__suggestPage)
1104
1105    def triggerSearch(self):
1106        """
1107        Trigger action search. This changes to current page to the
1108        'Suggest' page and sets the keyboard focus to the search line edit.
1109
1110        """
1111        self.__pages.setCurrentPage(self.__suggestPage)
1112        self.__search.setFocus(Qt.ShortcutFocusReason)
1113
1114        # Make sure that the first enabled item is set current.
1115        self.__suggestPage.ensureCurrent()
1116
1117    def keyPressEvent(self, event):
1118        if event.text():
1119            # Ignore modifiers, ...
1120            self.__search.setFocus(Qt.ShortcutFocusReason)
1121            self.setCurrentIndex(0)
1122            self.__search.keyPressEvent(event)
1123
1124        FramelessWindow.keyPressEvent(self, event)
1125        event.accept()
1126
1127    def event(self, event):
1128        if event.type() == QEvent.ShortcutOverride:
1129            log.debug("Overriding shortcuts")
1130            event.accept()
1131            return True
1132        return FramelessWindow.event(self, event)
1133
1134    def eventFilter(self, obj, event):
1135        if isinstance(obj, QTreeView):
1136            etype = event.type()
1137            if etype == QEvent.KeyPress:
1138                # ignore modifiers non printable characters, Enter, ...
1139                if event.text() and event.key() not in \
1140                        [Qt.Key_Enter, Qt.Key_Return]:
1141                    self.__search.setFocus(Qt.ShortcutFocusReason)
1142                    self.setCurrentIndex(0)
1143                    self.__search.keyPressEvent(event)
1144                    return True
1145
1146        return FramelessWindow.eventFilter(self, obj, event)
1147
1148
1149class ItemViewKeyNavigator(QObject):
1150    """
1151    A event filter class listening to key press events and responding
1152    by moving 'currentItem` on a :class:`QListView`.
1153
1154    """
1155    def __init__(self, parent=None):
1156        QObject.__init__(self, parent)
1157        self.__view = None
1158
1159    def setView(self, view):
1160        """
1161        Set the QListView.
1162        """
1163        if self.__view != view:
1164            self.__view = view
1165
1166    def view(self):
1167        """
1168        Return the view
1169        """
1170        return self.__view
1171
1172    def eventFilter(self, obj, event):
1173        etype = event.type()
1174        if etype == QEvent.KeyPress:
1175            key = event.key()
1176            if key == Qt.Key_Down:
1177                self.moveCurrent(1, 0)
1178                return True
1179            elif key == Qt.Key_Up:
1180                self.moveCurrent(-1, 0)
1181                return True
1182            elif key == Qt.Key_Tab:
1183                self.moveCurrent(0, 1)
1184                return  True
1185            elif key == Qt.Key_Enter or key == Qt.Key_Return:
1186                self.activateCurrent()
1187                return True
1188
1189        return QObject.eventFilter(self, obj, event)
1190
1191    def moveCurrent(self, rows, columns=0):
1192        """
1193        Move the current index by rows, columns.
1194        """
1195        if self.__view is not None:
1196            view = self.__view
1197            model = view.model()
1198
1199            curr = view.currentIndex()
1200            curr_row, curr_col = curr.row(), curr.column()
1201
1202            sign = 1 if rows >= 0 else -1
1203            row = curr_row + rows
1204
1205            row_count = model.rowCount()
1206            for i in range(row_count):
1207                index = model.index((row + sign * i) % row_count, 0)
1208                if index.flags() & Qt.ItemIsEnabled:
1209                    view.setCurrentIndex(index)
1210                    break
1211            # TODO: move by columns
1212
1213    def activateCurrent(self):
1214        """
1215        Activate the current index.
1216        """
1217        if self.__view is not None:
1218            curr = self.__view.currentIndex()
1219            if curr.isValid():
1220                # TODO: Does this work? We are emitting signals that are
1221                # defined by a different class. This might break some things.
1222                # Should we just send the keyPress events to the view, and let
1223                # it handle them.
1224                self.__view.activated.emit(curr)
1225
1226    def ensureCurrent(self):
1227        """
1228        Ensure the view has a current item if one is available.
1229        """
1230        if self.__view is not None:
1231            model = self.__view.model()
1232            curr = self.__view.currentIndex()
1233            if not curr.isValid():
1234                for i in range(model.rowCount()):
1235                    index = model.index(i, 0)
1236                    if index.flags() & Qt.ItemIsEnabled:
1237                        self.__view.setCurrentIndex(index)
1238                        break
1239
1240
1241class WindowSizeGrip(QSizeGrip):
1242    """
1243    Automatically positioning :class:`QSizeGrip`.
1244    The widget automatically maintains its position in the window
1245    corner during resize events.
1246
1247    """
1248    def __init__(self, parent):
1249        QSizeGrip.__init__(self, parent)
1250        self.__corner = Qt.BottomRightCorner
1251
1252        self.resize(self.sizeHint())
1253
1254        self.__updatePos()
1255
1256    def setCorner(self, corner):
1257        """
1258        Set the corner (:class:`Qt.Corner`) where the size grip should
1259        position itself.
1260
1261        """
1262        if corner not in [Qt.TopLeftCorner, Qt.TopRightCorner,
1263                          Qt.BottomLeftCorner, Qt.BottomRightCorner]:
1264            raise ValueError("Qt.Corner flag expected")
1265
1266        if self.__corner != corner:
1267            self.__corner = corner
1268            self.__updatePos()
1269
1270    def corner(self):
1271        """
1272        Return the corner where the size grip is positioned.
1273        """
1274        return self.__corner
1275
1276    def eventFilter(self, obj, event):
1277        if obj is self.window():
1278            if event.type() == QEvent.Resize:
1279                self.__updatePos()
1280
1281        return QSizeGrip.eventFilter(self, obj, event)
1282
1283    def showEvent(self, event):
1284        if self.window() != self.parent():
1285            log.error("%s: Can only show on a top level window.",
1286                      type(self).__name__)
1287
1288        return QSizeGrip.showEvent(self, event)
1289
1290    def __updatePos(self):
1291        window = self.window()
1292
1293        if window is not self.parent():
1294            return
1295
1296        corner = self.__corner
1297        size = self.sizeHint()
1298
1299        window_geom = window.geometry()
1300        window_size = window_geom.size()
1301
1302        if corner in [Qt.TopLeftCorner, Qt.BottomLeftCorner]:
1303            x = 0
1304        else:
1305            x = window_geom.width() - size.width()
1306
1307        if corner in [Qt.TopLeftCorner, Qt.TopRightCorner]:
1308            y = 0
1309        else:
1310            y = window_size.height() - size.height()
1311
1312        self.move(x, y)
1313
1314
1315def create_css_gradient(base_color):
1316    """
1317    Create a Qt css linear gradient fragment based on the `base_color`.
1318    """
1319    grad = create_tab_gradient(base_color)
1320    stops = grad.stops()
1321    stops = "\n".join("    stop: {0:f} {1}".format(stop, color.name())
1322                      for stop, color in stops)
1323    return ("qlineargradient(\n"
1324            "    x1: 0, y1: 0, x2: 0, y2: 1,\n"
1325            "{0})").format(stops)
Note: See TracBrowser for help on using the repository browser.