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

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

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

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