source: orange/Orange/OrangeCanvas/document/quickmenu.py @ 11499:677ba655a133

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

Fixed new current menu selection on a timeout.

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