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

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

Changed MenuPage size hint computation.

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

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