source: orange/Orange/OrangeCanvas/document/quickmenu.py @ 11498:373539640304

Revision 11498:373539640304, 37.9 KB checked in by Ales Erjavec <ales.erjavec@…>, 11 months ago (diff)

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