source: orange/Orange/OrangeCanvas/document/quickmenu.py @ 11500:467840f078f7

Revision 11500:467840f078f7, 38.5 KB checked in by Ales Erjavec <ales.erjavec@…>, 11 months ago (diff)

Small style fixes.

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