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

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

Small style fixes.

RevLine 
[11108]1"""
[11370]2==========
3Quick Menu
4==========
5
6A :class:`QuickMenu` widget provides lists of actions organized in tabs
7with a quick search functionality.
[11108]8
9"""
[11370]10
[11131]11import sys
12import logging
[11108]13
[11229]14from collections import namedtuple, Callable
[11108]15
[11223]16import numpy
17
[11108]18from PyQt4.QtGui import (
[11187]19    QWidget, QFrame, QToolButton, QAbstractButton, QAction, QIcon, QTreeView,
[11108]20    QButtonGroup, QStackedWidget, QHBoxLayout, QVBoxLayout, QSizePolicy,
21    QStandardItemModel, QSortFilterProxyModel, QStyleOptionToolButton,
[11187]22    QStylePainter, QStyle, QApplication, QStyledItemDelegate,
[11498]23    QStyleOptionViewItemV4, QSizeGrip, QCursor, QPolygon, QRegion
[11108]24)
25
26from PyQt4.QtCore import pyqtSignal as Signal
27from PyQt4.QtCore import pyqtProperty as Property
28
29from PyQt4.QtCore import (
[11498]30    Qt, QObject, QPoint, QSize, QRect, QEventLoop, QEvent, QModelIndex,
31    QTimer
[11108]32)
33
34
35from ..gui.framelesswindow import FramelessWindow
36from ..gui.lineedit import LineEdit
37from ..gui.tooltree import ToolTree, FlattenedTreeItemModel
[11491]38from ..gui.toolgrid import ToolButtonEventListener
39from ..gui.toolbox import create_tab_gradient
[11108]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
[11491]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
[11370]63class MenuPage(ToolTree):
64    """
[11371]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
[11420]67    :func:`setFilterFunc`.
[11371]68
[11370]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
[11491]82        self.view().setItemDelegate(_MenuItemDelegate(self.view()))
[11370]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
[11420]100    title_ = Property(unicode, fget=title, fset=setTitle,
101                      doc="Title of the page.")
[11370]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
[11420]117    icon_ = Property(QIcon, fget=icon, fset=setIcon,
118                     doc="Page icon")
[11370]119
120    def setFilterFunc(self, func):
[11371]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        """
[11370]128        proxyModel = self.view().model()
129        proxyModel.setFilterFunc(func)
130
131    def setModel(self, model):
[11371]132        """
[11420]133        Reimplemented from :func:`ToolTree.setModel`.
[11371]134        """
[11370]135        proxyModel = ItemDisableFilter(self)
136        proxyModel.setSourceModel(model)
137        ToolTree.setModel(self, proxyModel)
138
139    def setRootIndex(self, index):
[11371]140        """
[11420]141        Reimplemented from :func:`ToolTree.setRootIndex`
[11371]142        """
[11370]143        proxyModel = self.view().model()
144        mappedIndex = proxyModel.mapFromSource(index)
145        ToolTree.setRootIndex(self, mappedIndex)
146
147    def rootIndex(self):
[11371]148        """
[11420]149        Reimplemented from :func:`ToolTree.rootIndex`
[11371]150        """
[11370]151        proxyModel = self.view().model()
152        return proxyModel.mapToSource(ToolTree.rootIndex(self))
153
[11494]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
[11370]171
172class ItemDisableFilter(QSortFilterProxyModel):
[11371]173    """
174    An filter proxy model used to disable selected items based on
175    a filtering function.
176
177    """
[11370]178    def __init__(self, parent=None):
179        QSortFilterProxyModel.__init__(self, parent)
180
181        self.__filterFunc = None
182
183    def setFilterFunc(self, func):
[11371]184        """
185        Set the filtering function.
186        """
[11370]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):
[11371]197        """
198        Reimplemented from :class:`QSortFilterProxyModel.flags`
199        """
[11370]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):
[11371]212    """
213    A MenuMage for the QuickMenu widget supporting item filtering
214    (searching).
215
216    """
[11370]217    def __init__(self, *args, **kwargs):
218        MenuPage.__init__(self, *args, **kwargs)
219
220    def setModel(self, model):
[11371]221        """
222        Reimplmemented from :ref:`MenuPage.setModel`.
223        """
[11370]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):
[11371]235        """
236        Set the fixed string filtering pattern. Only items which contain the
237        `pattern` string will be shown.
238
239        """
[11370]240        proxy = self.view().model()
241        proxy.setFilterFixedString(pattern)
242        self.ensureCurrent()
243
244    def setFilterRegExp(self, pattern):
[11371]245        """
246        Set the regular expression filtering pattern. Only items matching
247        the `pattern` expression will be shown.
248
249        """
[11370]250        filter_proxy = self.view().model()
251        filter_proxy.setFilterRegExp(pattern)
252        self.ensureCurrent()
253
254    def setFilterWildCard(self, pattern):
[11371]255        """
256        Set a wildcard filtering pattern.
257        """
[11370]258        filter_proxy = self.view().model()
259        filter_proxy.setFilterWildCard(pattern)
260        self.ensureCurrent()
261
262    def setFilterFunc(self, func):
[11371]263        """
264        Set a filtering function.
265        """
[11370]266        filter_proxy = self.view().model()
267        filter_proxy.setFilterFunc(func)
268
269
270class SortFilterProxyModel(QSortFilterProxyModel):
[11371]271    """
272    An filter proxy model used to filter items based on a filtering
273    function.
274
275    """
[11370]276    def __init__(self, parent=None):
277        QSortFilterProxyModel.__init__(self, parent)
278
279        self.__filterFunc = None
280
281    def setFilterFunc(self, func):
[11371]282        """
283        Set the filtering function.
284        """
[11370]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
[11108]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):
[11370]317    """
318    Stack widget for the menu pages.
[11108]319    """
320
321    def sizeHint(self):
[11370]322        """
323        Size hint is the maximum width and median height of the widgets
324        contained in the stack.
[11108]325
326        """
327        default_size = QSize(200, 400)
328        widget_hints = [default_size]
329        for i in range(self.count()):
[11494]330            hint = self.widget(i).sizeHint()
[11108]331            widget_hints.append(hint)
[11494]332
[11108]333        width = max([s.width() for s in widget_hints])
334        # Take the median for the height
[11223]335        height = numpy.median([s.height() for s in widget_hints])
336
337        return QSize(width, int(height))
[11108]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
[11110]349        else:
350            height = hint.height()
[11108]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
[11500]362        self.__showMenuIndicator = False
[11108]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
[11500]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
[11108]387    def paintEvent(self, event):
[11491]388        opt = QStyleOptionToolButton()
389        self.initStyleOption(opt)
[11500]390        if self.__showMenuIndicator and self.isChecked():
391            opt.features |= QStyleOptionToolButton.HasMenu
[11108]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:
[11491]399            p = QStylePainter(self)
400            p.drawComplexControl(QStyle.CC_ToolButton, opt)
[11108]401
[11491]402    def sizeHint(self):
403        opt = QStyleOptionToolButton()
404        self.initStyleOption(opt)
[11500]405        if self.__showMenuIndicator and self.isChecked():
406            opt.features |= QStyleOptionToolButton.HasMenu
[11491]407        style = self.style()
408
409        hint = style.sizeFromContents(QStyle.CT_ToolButton, opt,
410                                      opt.iconSize, self)
411        return hint
[11108]412
413_Tab = \
414    namedtuple(
415        "_Tab",
416        ["text",
417         "icon",
418         "toolTip",
419         "button",
420         "data",
421         "palette"])
422
423
424class TabBarWidget(QWidget):
[11371]425    """
426    A tab bar widget using tool buttons as tabs.
[11108]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)
[11491]434        layout = QVBoxLayout()
[11108]435        layout.setContentsMargins(0, 0, 0, 0)
436        layout.setSpacing(0)
437        self.setLayout(layout)
438
[11491]439        self.setSizePolicy(QSizePolicy.Fixed,
440                           QSizePolicy.Expanding)
[11108]441        self.__tabs = []
[11491]442
[11108]443        self.__currentIndex = -1
[11491]444        self.__changeOnHover = False
445
446        self.__iconSize = QSize(26, 26)
447
[11108]448        self.__group = QButtonGroup(self, exclusive=True)
449        self.__group.buttonPressed[QAbstractButton].connect(
450            self.__onButtonPressed
451        )
[11498]452        self.setMouseTracking(True)
[11108]453
[11498]454        self.__sloppyButton = None
455        self.__sloppyRegion = QRegion()
456        self.__sloppyTimer = QTimer(self, singleShot=True)
457        self.__sloppyTimer.timeout.connect(self.__onSloppyTimeout)
[11491]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
[11108]474    def count(self):
[11371]475        """
476        Return the number of tabs in the widget.
[11108]477        """
478        return len(self.__tabs)
479
480    def addTab(self, text, icon=None, toolTip=None):
[11371]481        """
482        Add a new tab and return it's index.
[11108]483        """
484        return self.insertTab(self.count(), text, icon, toolTip)
485
[11371]486    def insertTab(self, index, text, icon=None, toolTip=None):
487        """
488        Insert a tab at `index`
[11108]489        """
490        button = TabButton(self, objectName="tab-button")
[11131]491        button.setSizePolicy(QSizePolicy.Expanding,
492                             QSizePolicy.Expanding)
[11491]493        button.setIconSize(self.__iconSize)
[11498]494        button.setMouseTracking(True)
[11108]495
496        self.__group.addButton(button)
[11491]497
[11498]498        button.installEventFilter(self)
[11491]499
[11108]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):
[11371]511        """
512        Remove a tab at `index`.
513        """
[11108]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)
[11491]518
[11498]519            tab.button.removeEventFilter(self)
520
521            if tab.button is self.__sloppyButton:
522                self.__sloppyButton = None
523                self.__sloppyRegion = QRegion()
[11491]524
[11108]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):
[11371]534        """
535        Set the `icon` for tab at `index`.
[11108]536        """
537        self.__tabs[index] = self.__tabs[index]._replace(icon=icon)
538        self.__updateTab(index)
539
540    def setTabToolTip(self, index, toolTip):
[11371]541        """
542        Set `toolTip` for tab at `index`.
[11108]543        """
544        self.__tabs[index] = self.__tabs[index]._replace(toolTip=toolTip)
545        self.__updateTab(index)
546
547    def setTabText(self, index, text):
[11371]548        """
549        Set tab `text` for tab at `index`
[11108]550        """
551        self.__tabs[index] = self.__tabs[index]._replace(text=text)
552        self.__updateTab(index)
553
554    def setTabPalette(self, index, palette):
[11371]555        """
556        Set the tab button palette.
[11108]557        """
558        self.__tabs[index] = self.__tabs[index]._replace(palette=palette)
559        self.__updateTab(index)
560
561    def setCurrentIndex(self, index):
[11371]562        """
563        Set the current tab index.
564        """
[11108]565        if self.__currentIndex != index:
566            self.__currentIndex = index
[11131]567
[11498]568            self.__sloppyRegion = QRegion()
569            self.__sloppyButton = None
570
[11131]571            if index != -1:
572                self.__tabs[index].button.setChecked(True)
573
[11108]574            self.currentChanged.emit(index)
575
[11371]576    def currentIndex(self):
577        """
578        Return the current index.
579        """
580        return self.__currentIndex
581
[11108]582    def button(self, index):
[11371]583        """
584        Return the `TabButton` instance for index.
[11108]585        """
586        return self.__tabs[index].button
587
[11491]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
[11371]594    def __updateTab(self, index):
[11108]595        """
[11371]596        Update the tab button.
[11108]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
[11498]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():
[11499]645                index = [tab.button for tab in self.__tabs].index(button)
646                self.setCurrentIndex(index)
[11498]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)
[11491]672
[11108]673
674class PagedMenu(QWidget):
[11370]675    """
676    Tabbed container for :class:`MenuPage` instances.
[11108]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
[11491]689        layout = QHBoxLayout()
[11108]690        layout.setContentsMargins(0, 0, 0, 0)
[11500]691        layout.setSpacing(6)
[11108]692
693        self.__tab = TabBarWidget(self)
694        self.__tab.currentChanged.connect(self.setCurrentIndex)
[11491]695        self.__tab.setChangeOnHover(True)
[11108]696
697        self.__stack = MenuStackWidget(self)
698
[11491]699        layout.addWidget(self.__tab, alignment=Qt.AlignTop)
[11108]700        layout.addWidget(self.__stack)
701
702        self.setLayout(layout)
703
704    def addPage(self, page, title, icon=None, toolTip=None):
[11371]705        """
706        Add a `page` to the menu and return its index.
[11108]707        """
708        return self.insertPage(self.count(), page, title, icon, toolTip)
709
710    def insertPage(self, index, page, title, icon=None, toolTip=None):
[11371]711        """
712        Insert `page` at `index`.
[11108]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)
[11131]719        return index
[11108]720
721    def page(self, index):
[11371]722        """
723        Return the page at index.
[11108]724        """
725        return self.__stack.widget(index)
726
727    def removePage(self, index):
[11371]728        """
729        Remove the page at `index`.
[11108]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):
[11371]739        """
740        Return the number of pages.
[11108]741        """
742        return self.__stack.count()
743
744    def setCurrentIndex(self, index):
[11371]745        """
746        Set the current page index.
[11108]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):
[11371]755        """
756        Return the index of the current page.
[11108]757        """
758        return self.__currentIndex
759
760    def setCurrentPage(self, page):
[11371]761        """
762        Set `page` to be the current shown page.
[11108]763        """
764        index = self.__stack.indexOf(page)
765        self.setCurrentIndex(index)
766
767    def currentPage(self):
[11371]768        """
769        Return the current page.
[11108]770        """
771        return self.__stack.currentWidget()
772
773    def indexOf(self, page):
[11371]774        """
775        Return the index of `page`.
[11108]776        """
777        return self.__stack.indexOf(page)
778
[11131]779    def tabButton(self, index):
[11371]780        """
781        Return the tab button instance for index.
[11131]782        """
783        return self.__tab.button(index)
784
[11108]785
[11500]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
[11108]803class QuickMenu(FramelessWindow):
[11371]804    """
805    A quick menu popup for the widgets.
[11108]806
[11420]807    The widgets are set using :func:`QuickMenu.setModel` which must be a
808    model as returned by :func:`QtWidgetRegistry.model`
[11108]809
810    """
811
[11420]812    #: An action has been triggered in the menu.
[11108]813    triggered = Signal(QAction)
[11420]814
815    #: An action has been hovered in the menu
[11108]816    hovered = Signal(QAction)
817
818    def __init__(self, parent=None, **kwargs):
819        FramelessWindow.__init__(self, parent, **kwargs)
820        self.setWindowFlags(Qt.Popup)
821
[11229]822        self.__filterFunc = None
823
[11108]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
[11491]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
[11108]842        self.__frame = QFrame(self, objectName="menu-frame")
843        layout = QVBoxLayout()
[11491]844        layout.setContentsMargins(0, 0, 0, 0)
[11108]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
[11229]864        if sys.platform == "darwin":
865            view = self.__suggestPage.view()
866            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
[11491]867            # Don't show the focus frame because it expands into the tab bar.
[11229]868            view.setAttribute(Qt.WA_MacShowFocusRect, False)
869
[11491]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")
[11108]878
[11229]879        self.__search.textEdited.connect(self.__on_textEdited)
[11108]880
881        self.__navigator = ItemViewKeyNavigator(self)
882        self.__navigator.setView(self.__suggestPage.view())
883        self.__search.installEventFilter(self.__navigator)
884
[11223]885        self.__grip = WindowSizeGrip(self)
886        self.__grip.raise_()
887
888    def setSizeGripEnabled(self, enabled):
[11371]889        """
890        Enable the resizing of the menu with a size grip in a bottom
[11223]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):
[11371]903        """
904        Is the size grip enabled.
[11223]905        """
906        return bool(self.__grip)
907
[11108]908    def addPage(self, name, page):
[11371]909        """
[11420]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
[11108]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)
[11187]925
[11420]926        # Install event filter to intercept key presses.
[11187]927        page.view().installEventFilter(self)
928
[11108]929        return index
930
931    def createPage(self, index):
[11371]932        """
933        Create a new page based on the contents of an index
934        (:class:`QModeIndex`) item.
935
936        """
[11229]937        page = MenuPage(self)
[11187]938
[11108]939        page.setModel(index.model())
940        page.setRootIndex(index)
941
[11131]942        view = page.view()
943
944        if sys.platform == "darwin":
945            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
[11187]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)
[11131]949
[11108]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):
[11371]961        """
962        Set the model containing the actions.
963        """
[11108]964        root = model.invisibleRootItem()
965        for i in range(root.rowCount()):
966            item = root.child(i)
[11131]967            index = item.index()
968            page = self.createPage(index)
[11108]969            page.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE)
[11131]970            i = self.addPage(page.title(), page)
971
[11133]972            brush = index.data(QtWidgetRegistry.BACKGROUND_ROLE)
973
[11131]974            if brush.isValid():
975                brush = brush.toPyObject()
[11491]976                base_color = brush.color()
[11131]977                button = self.__pages.tabButton(i)
978                button.setStyleSheet(
[11500]979                    TAB_BUTTON_STYLE_TEMPLATE %
980                    (create_css_gradient(base_color),
981                     create_css_gradient(base_color.darker(120)))
[11131]982                )
983
[11108]984        self.__model = model
985        self.__suggestPage.setModel(model)
986
[11164]987    def setFilterFunc(self, func):
[11371]988        """
989        Set a filter function.
990        """
[11229]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)
[11164]995
[11489]996    def popup(self, pos=None, searchText=""):
[11371]997        """
[11489]998        Popup the menu at `pos` (in screen coordinates). 'Search' text field
999        is initialized with `searchText` if provided.
[11108]1000        """
1001        if pos is None:
1002            pos = QPoint()
1003
[11489]1004        self.__search.setText(searchText)
1005        self.__suggestPage.setFilterFixedString(searchText)
[11131]1006
[11108]1007        self.ensurePolished()
[11223]1008
1009        if self.testAttribute(Qt.WA_Resized) and self.sizeGripEnabled():
1010            size = self.size()
1011        else:
1012            size = self.sizeHint()
1013
[11108]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
[11489]1047        if searchText:
1048            self.setFocusProxy(self.__search)
1049        else:
1050            self.setFocusProxy(None)
1051
1052    def exec_(self, pos=None, searchText=""):
[11420]1053        """
1054        Execute the menu at position `pos` (in global screen coordinates).
1055        Return the triggered :class:`QAction` or `None` if no action was
[11489]1056        triggered. 'Search' text field is initialized with `searchText` if
1057        provided.
[11420]1058
1059        """
[11489]1060        self.popup(pos, searchText)
[11131]1061        self.setFocus(Qt.PopupFocusReason)
1062
[11108]1063        self.__triggeredAction = None
[11209]1064        self.__loop = QEventLoop()
[11108]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):
[11420]1074        """
1075        Reimplemented from :class:`QWidget`
1076        """
[11108]1077        FramelessWindow.hideEvent(self, event)
1078        if self.__loop:
1079            self.__loop.exit()
1080
1081    def setCurrentPage(self, page):
[11371]1082        """
1083        Set the current shown page to `page`.
1084        """
[11108]1085        self.__pages.setCurrentPage(page)
1086
1087    def setCurrentIndex(self, index):
[11371]1088        """
1089        Set the current page index.
1090        """
[11108]1091        self.__pages.setCurrentIndex(index)
1092
1093    def __onTriggered(self, action):
[11371]1094        """
1095        Re-emit the action from the page.
[11108]1096        """
1097        self.__triggeredAction = action
1098
1099        # Hide and exit the event loop if necessary.
1100        self.hide()
1101        self.triggered.emit(action)
1102
[11187]1103    def __on_textEdited(self, text):
1104        self.__suggestPage.setFilterFixedString(text)
1105        self.__pages.setCurrentPage(self.__suggestPage)
1106
[11108]1107    def triggerSearch(self):
[11371]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        """
[11187]1113        self.__pages.setCurrentPage(self.__suggestPage)
[11108]1114        self.__search.setFocus(Qt.ShortcutFocusReason)
[11131]1115
[11108]1116        # Make sure that the first enabled item is set current.
1117        self.__suggestPage.ensureCurrent()
1118
1119    def keyPressEvent(self, event):
[11131]1120        if event.text():
[11187]1121            # Ignore modifiers, ...
[11131]1122            self.__search.setFocus(Qt.ShortcutFocusReason)
1123            self.setCurrentIndex(0)
1124            self.__search.keyPressEvent(event)
1125
[11108]1126        FramelessWindow.keyPressEvent(self, event)
[11187]1127        event.accept()
1128
[11462]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
[11187]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
[11108]1151class ItemViewKeyNavigator(QObject):
[11420]1152    """
1153    A event filter class listening to key press events and responding
1154    by moving 'currentItem` on a :class:`QListView`.
1155
1156    """
[11108]1157    def __init__(self, parent=None):
1158        QObject.__init__(self, parent)
1159        self.__view = None
1160
1161    def setView(self, view):
[11420]1162        """
1163        Set the QListView.
1164        """
[11108]1165        if self.__view != view:
1166            self.__view = view
1167
1168    def view(self):
[11420]1169        """
1170        Return the view
1171        """
[11108]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):
[11371]1194        """
1195        Move the current index by rows, columns.
[11108]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):
[11371]1216        """
1217        Activate the current index.
[11108]1218        """
1219        if self.__view is not None:
1220            curr = self.__view.currentIndex()
1221            if curr.isValid():
[11420]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.
[11108]1226                self.__view.activated.emit(curr)
1227
1228    def ensureCurrent(self):
[11371]1229        """
1230        Ensure the view has a current item if one is available.
[11108]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
[11223]1241
1242
1243class WindowSizeGrip(QSizeGrip):
[11371]1244    """
1245    Automatically positioning :class:`QSizeGrip`.
[11420]1246    The widget automatically maintains its position in the window
1247    corner during resize events.
1248
[11223]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):
[11371]1259        """
[11420]1260        Set the corner (:class:`Qt.Corner`) where the size grip should
1261        position itself.
1262
[11223]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):
[11371]1273        """
1274        Return the corner where the size grip is positioned.
[11223]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)
[11491]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.