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.

Line 
1"""
2==========
3Quick Menu
4==========
5
6A :class:`QuickMenu` widget provides lists of actions organized in tabs
7with a quick search functionality.
8
9"""
10
11import sys
12import logging
13
14from collections import namedtuple, Callable
15
16import numpy
17
18from PyQt4.QtGui import (
19    QWidget, QFrame, QToolButton, QAbstractButton, QAction, QIcon, QTreeView,
20    QButtonGroup, QStackedWidget, QHBoxLayout, QVBoxLayout, QSizePolicy,
21    QStandardItemModel, QSortFilterProxyModel, QStyleOptionToolButton,
22    QStylePainter, QStyle, QApplication, QStyledItemDelegate,
23    QStyleOptionViewItemV4, QSizeGrip, QCursor, QPolygon, QRegion
24)
25
26from PyQt4.QtCore import pyqtSignal as Signal
27from PyQt4.QtCore import pyqtProperty as Property
28
29from PyQt4.QtCore import (
30    Qt, QObject, QPoint, QSize, QRect, QEventLoop, QEvent, QModelIndex,
31    QTimer
32)
33
34
35from ..gui.framelesswindow import FramelessWindow
36from ..gui.lineedit import LineEdit
37from ..gui.tooltree import ToolTree, FlattenedTreeItemModel
38from ..gui.toolgrid import ToolButtonEventListener
39from ..gui.toolbox import create_tab_gradient
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
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
63class MenuPage(ToolTree):
64    """
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
67    :func:`setFilterFunc`.
68
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
82        self.view().setItemDelegate(_MenuItemDelegate(self.view()))
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
100    title_ = Property(unicode, fget=title, fset=setTitle,
101                      doc="Title of the page.")
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
117    icon_ = Property(QIcon, fget=icon, fset=setIcon,
118                     doc="Page icon")
119
120    def setFilterFunc(self, func):
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        """
128        proxyModel = self.view().model()
129        proxyModel.setFilterFunc(func)
130
131    def setModel(self, model):
132        """
133        Reimplemented from :func:`ToolTree.setModel`.
134        """
135        proxyModel = ItemDisableFilter(self)
136        proxyModel.setSourceModel(model)
137        ToolTree.setModel(self, proxyModel)
138
139    def setRootIndex(self, index):
140        """
141        Reimplemented from :func:`ToolTree.setRootIndex`
142        """
143        proxyModel = self.view().model()
144        mappedIndex = proxyModel.mapFromSource(index)
145        ToolTree.setRootIndex(self, mappedIndex)
146
147    def rootIndex(self):
148        """
149        Reimplemented from :func:`ToolTree.rootIndex`
150        """
151        proxyModel = self.view().model()
152        return proxyModel.mapToSource(ToolTree.rootIndex(self))
153
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
171
172class ItemDisableFilter(QSortFilterProxyModel):
173    """
174    An filter proxy model used to disable selected items based on
175    a filtering function.
176
177    """
178    def __init__(self, parent=None):
179        QSortFilterProxyModel.__init__(self, parent)
180
181        self.__filterFunc = None
182
183    def setFilterFunc(self, func):
184        """
185        Set the filtering function.
186        """
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):
197        """
198        Reimplemented from :class:`QSortFilterProxyModel.flags`
199        """
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):
212    """
213    A MenuMage for the QuickMenu widget supporting item filtering
214    (searching).
215
216    """
217    def __init__(self, *args, **kwargs):
218        MenuPage.__init__(self, *args, **kwargs)
219
220    def setModel(self, model):
221        """
222        Reimplmemented from :ref:`MenuPage.setModel`.
223        """
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):
235        """
236        Set the fixed string filtering pattern. Only items which contain the
237        `pattern` string will be shown.
238
239        """
240        proxy = self.view().model()
241        proxy.setFilterFixedString(pattern)
242        self.ensureCurrent()
243
244    def setFilterRegExp(self, pattern):
245        """
246        Set the regular expression filtering pattern. Only items matching
247        the `pattern` expression will be shown.
248
249        """
250        filter_proxy = self.view().model()
251        filter_proxy.setFilterRegExp(pattern)
252        self.ensureCurrent()
253
254    def setFilterWildCard(self, pattern):
255        """
256        Set a wildcard filtering pattern.
257        """
258        filter_proxy = self.view().model()
259        filter_proxy.setFilterWildCard(pattern)
260        self.ensureCurrent()
261
262    def setFilterFunc(self, func):
263        """
264        Set a filtering function.
265        """
266        filter_proxy = self.view().model()
267        filter_proxy.setFilterFunc(func)
268
269
270class SortFilterProxyModel(QSortFilterProxyModel):
271    """
272    An filter proxy model used to filter items based on a filtering
273    function.
274
275    """
276    def __init__(self, parent=None):
277        QSortFilterProxyModel.__init__(self, parent)
278
279        self.__filterFunc = None
280
281    def setFilterFunc(self, func):
282        """
283        Set the filtering function.
284        """
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
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):
317    """
318    Stack widget for the menu pages.
319    """
320
321    def sizeHint(self):
322        """
323        Size hint is the maximum width and median height of the widgets
324        contained in the stack.
325
326        """
327        default_size = QSize(200, 400)
328        widget_hints = [default_size]
329        for i in range(self.count()):
330            hint = self.widget(i).sizeHint()
331            widget_hints.append(hint)
332
333        width = max([s.width() for s in widget_hints])
334        # Take the median for the height
335        height = numpy.median([s.height() for s in widget_hints])
336
337        return QSize(width, int(height))
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
349        else:
350            height = hint.height()
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):
375        opt = QStyleOptionToolButton()
376        self.initStyleOption(opt)
377        opt.features |= QStyleOptionToolButton.HasMenu
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:
385            p = QStylePainter(self)
386            p.drawComplexControl(QStyle.CC_ToolButton, opt)
387
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
397
398_Tab = \
399    namedtuple(
400        "_Tab",
401        ["text",
402         "icon",
403         "toolTip",
404         "button",
405         "data",
406         "palette"])
407
408
409class TabBarWidget(QWidget):
410    """
411    A tab bar widget using tool buttons as tabs.
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)
419        layout = QVBoxLayout()
420        layout.setContentsMargins(0, 0, 0, 0)
421        layout.setSpacing(0)
422        self.setLayout(layout)
423
424        self.setSizePolicy(QSizePolicy.Fixed,
425                           QSizePolicy.Expanding)
426        self.__tabs = []
427
428        self.__currentIndex = -1
429        self.__changeOnHover = False
430
431        self.__iconSize = QSize(26, 26)
432
433        self.__group = QButtonGroup(self, exclusive=True)
434        self.__group.buttonPressed[QAbstractButton].connect(
435            self.__onButtonPressed
436        )
437        self.setMouseTracking(True)
438
439        self.__sloppyButton = None
440        self.__sloppyRegion = QRegion()
441        self.__sloppyTimer = QTimer(self, singleShot=True)
442        self.__sloppyTimer.timeout.connect(self.__onSloppyTimeout)
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
459    def count(self):
460        """
461        Return the number of tabs in the widget.
462        """
463        return len(self.__tabs)
464
465    def addTab(self, text, icon=None, toolTip=None):
466        """
467        Add a new tab and return it's index.
468        """
469        return self.insertTab(self.count(), text, icon, toolTip)
470
471    def insertTab(self, index, text, icon=None, toolTip=None):
472        """
473        Insert a tab at `index`
474        """
475        button = TabButton(self, objectName="tab-button")
476        button.setSizePolicy(QSizePolicy.Expanding,
477                             QSizePolicy.Expanding)
478        button.setIconSize(self.__iconSize)
479        button.setMouseTracking(True)
480
481        self.__group.addButton(button)
482
483        button.installEventFilter(self)
484
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):
496        """
497        Remove a tab at `index`.
498        """
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)
503
504            tab.button.removeEventFilter(self)
505
506            if tab.button is self.__sloppyButton:
507                self.__sloppyButton = None
508                self.__sloppyRegion = QRegion()
509
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):
519        """
520        Set the `icon` for tab at `index`.
521        """
522        self.__tabs[index] = self.__tabs[index]._replace(icon=icon)
523        self.__updateTab(index)
524
525    def setTabToolTip(self, index, toolTip):
526        """
527        Set `toolTip` for tab at `index`.
528        """
529        self.__tabs[index] = self.__tabs[index]._replace(toolTip=toolTip)
530        self.__updateTab(index)
531
532    def setTabText(self, index, text):
533        """
534        Set tab `text` for tab at `index`
535        """
536        self.__tabs[index] = self.__tabs[index]._replace(text=text)
537        self.__updateTab(index)
538
539    def setTabPalette(self, index, palette):
540        """
541        Set the tab button palette.
542        """
543        self.__tabs[index] = self.__tabs[index]._replace(palette=palette)
544        self.__updateTab(index)
545
546    def setCurrentIndex(self, index):
547        """
548        Set the current tab index.
549        """
550        if self.__currentIndex != index:
551            self.__currentIndex = index
552
553            self.__sloppyRegion = QRegion()
554            self.__sloppyButton = None
555
556            if index != -1:
557                self.__tabs[index].button.setChecked(True)
558
559            self.currentChanged.emit(index)
560
561    def currentIndex(self):
562        """
563        Return the current index.
564        """
565        return self.__currentIndex
566
567    def button(self, index):
568        """
569        Return the `TabButton` instance for index.
570        """
571        return self.__tabs[index].button
572
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
579    def __updateTab(self, index):
580        """
581        Update the tab button.
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
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                index = [tab.button for tab in self.__tabs].index(button)
631                self.setCurrentIndex(index)
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)
657
658
659class PagedMenu(QWidget):
660    """
661    Tabbed container for :class:`MenuPage` instances.
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
674        layout = QHBoxLayout()
675        layout.setContentsMargins(0, 0, 0, 0)
676        layout.setSpacing(0)
677
678        self.__tab = TabBarWidget(self)
679        self.__tab.currentChanged.connect(self.setCurrentIndex)
680        self.__tab.setChangeOnHover(True)
681
682        self.__stack = MenuStackWidget(self)
683
684        layout.addWidget(self.__tab, alignment=Qt.AlignTop)
685        layout.addWidget(self.__stack)
686
687        self.setLayout(layout)
688
689    def addPage(self, page, title, icon=None, toolTip=None):
690        """
691        Add a `page` to the menu and return its index.
692        """
693        return self.insertPage(self.count(), page, title, icon, toolTip)
694
695    def insertPage(self, index, page, title, icon=None, toolTip=None):
696        """
697        Insert `page` at `index`.
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)
704        return index
705
706    def page(self, index):
707        """
708        Return the page at index.
709        """
710        return self.__stack.widget(index)
711
712    def removePage(self, index):
713        """
714        Remove the page at `index`.
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):
724        """
725        Return the number of pages.
726        """
727        return self.__stack.count()
728
729    def setCurrentIndex(self, index):
730        """
731        Set the current page index.
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):
740        """
741        Return the index of the current page.
742        """
743        return self.__currentIndex
744
745    def setCurrentPage(self, page):
746        """
747        Set `page` to be the current shown page.
748        """
749        index = self.__stack.indexOf(page)
750        self.setCurrentIndex(index)
751
752    def currentPage(self):
753        """
754        Return the current page.
755        """
756        return self.__stack.currentWidget()
757
758    def indexOf(self, page):
759        """
760        Return the index of `page`.
761        """
762        return self.__stack.indexOf(page)
763
764    def tabButton(self, index):
765        """
766        Return the tab button instance for index.
767        """
768        return self.__tab.button(index)
769
770
771class QuickMenu(FramelessWindow):
772    """
773    A quick menu popup for the widgets.
774
775    The widgets are set using :func:`QuickMenu.setModel` which must be a
776    model as returned by :func:`QtWidgetRegistry.model`
777
778    """
779
780    #: An action has been triggered in the menu.
781    triggered = Signal(QAction)
782
783    #: An action has been hovered in the menu
784    hovered = Signal(QAction)
785
786    def __init__(self, parent=None, **kwargs):
787        FramelessWindow.__init__(self, parent, **kwargs)
788        self.setWindowFlags(Qt.Popup)
789
790        self.__filterFunc = None
791
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
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
810        self.__frame = QFrame(self, objectName="menu-frame")
811        layout = QVBoxLayout()
812        layout.setContentsMargins(0, 0, 0, 0)
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
832        if sys.platform == "darwin":
833            view = self.__suggestPage.view()
834            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
835            # Don't show the focus frame because it expands into the tab bar.
836            view.setAttribute(Qt.WA_MacShowFocusRect, False)
837
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")
846
847        self.__search.textEdited.connect(self.__on_textEdited)
848
849        self.__navigator = ItemViewKeyNavigator(self)
850        self.__navigator.setView(self.__suggestPage.view())
851        self.__search.installEventFilter(self.__navigator)
852
853        self.__grip = WindowSizeGrip(self)
854        self.__grip.raise_()
855
856    def setSizeGripEnabled(self, enabled):
857        """
858        Enable the resizing of the menu with a size grip in a bottom
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):
871        """
872        Is the size grip enabled.
873        """
874        return bool(self.__grip)
875
876    def addPage(self, name, page):
877        """
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
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)
893
894        # Install event filter to intercept key presses.
895        page.view().installEventFilter(self)
896
897        return index
898
899    def createPage(self, index):
900        """
901        Create a new page based on the contents of an index
902        (:class:`QModeIndex`) item.
903
904        """
905        page = MenuPage(self)
906
907        page.setModel(index.model())
908        page.setRootIndex(index)
909
910        view = page.view()
911
912        if sys.platform == "darwin":
913            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
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)
917
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):
929        """
930        Set the model containing the actions.
931        """
932        root = model.invisibleRootItem()
933        for i in range(root.rowCount()):
934            item = root.child(i)
935            index = item.index()
936            page = self.createPage(index)
937            page.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE)
938            i = self.addPage(page.title(), page)
939
940            brush = index.data(QtWidgetRegistry.BACKGROUND_ROLE)
941
942            if brush.isValid():
943                brush = brush.toPyObject()
944                base_color = brush.color()
945                button = self.__pages.tabButton(i)
946                button.setStyleSheet(
947                    "TabButton {\n"
948                    "    qproperty-flat_: false;\n"
949                    "    background: %s;\n"
950                    "    border: none;\n"
951                    "    border-bottom: 1px solid palette(dark);\n"
952                    "}\n"
953                    "TabButton:checked {\n"
954                    "    background: %s\n"
955                    "}" % (create_css_gradient(base_color),
956                           create_css_gradient(base_color.darker(110)))
957                )
958
959        self.__model = model
960        self.__suggestPage.setModel(model)
961
962    def setFilterFunc(self, func):
963        """
964        Set a filter function.
965        """
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)
970
971    def popup(self, pos=None, searchText=""):
972        """
973        Popup the menu at `pos` (in screen coordinates). 'Search' text field
974        is initialized with `searchText` if provided.
975        """
976        if pos is None:
977            pos = QPoint()
978
979        self.__search.setText(searchText)
980        self.__suggestPage.setFilterFixedString(searchText)
981
982        self.ensurePolished()
983
984        if self.testAttribute(Qt.WA_Resized) and self.sizeGripEnabled():
985            size = self.size()
986        else:
987            size = self.sizeHint()
988
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
1022        if searchText:
1023            self.setFocusProxy(self.__search)
1024        else:
1025            self.setFocusProxy(None)
1026
1027    def exec_(self, pos=None, searchText=""):
1028        """
1029        Execute the menu at position `pos` (in global screen coordinates).
1030        Return the triggered :class:`QAction` or `None` if no action was
1031        triggered. 'Search' text field is initialized with `searchText` if
1032        provided.
1033
1034        """
1035        self.popup(pos, searchText)
1036        self.setFocus(Qt.PopupFocusReason)
1037
1038        self.__triggeredAction = None
1039        self.__loop = QEventLoop()
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):
1049        """
1050        Reimplemented from :class:`QWidget`
1051        """
1052        FramelessWindow.hideEvent(self, event)
1053        if self.__loop:
1054            self.__loop.exit()
1055
1056    def setCurrentPage(self, page):
1057        """
1058        Set the current shown page to `page`.
1059        """
1060        self.__pages.setCurrentPage(page)
1061
1062    def setCurrentIndex(self, index):
1063        """
1064        Set the current page index.
1065        """
1066        self.__pages.setCurrentIndex(index)
1067
1068    def __onTriggered(self, action):
1069        """
1070        Re-emit the action from the page.
1071        """
1072        self.__triggeredAction = action
1073
1074        # Hide and exit the event loop if necessary.
1075        self.hide()
1076        self.triggered.emit(action)
1077
1078    def __on_textEdited(self, text):
1079        self.__suggestPage.setFilterFixedString(text)
1080        self.__pages.setCurrentPage(self.__suggestPage)
1081
1082    def triggerSearch(self):
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        """
1088        self.__pages.setCurrentPage(self.__suggestPage)
1089        self.__search.setFocus(Qt.ShortcutFocusReason)
1090
1091        # Make sure that the first enabled item is set current.
1092        self.__suggestPage.ensureCurrent()
1093
1094    def keyPressEvent(self, event):
1095        if event.text():
1096            # Ignore modifiers, ...
1097            self.__search.setFocus(Qt.ShortcutFocusReason)
1098            self.setCurrentIndex(0)
1099            self.__search.keyPressEvent(event)
1100
1101        FramelessWindow.keyPressEvent(self, event)
1102        event.accept()
1103
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
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
1126class ItemViewKeyNavigator(QObject):
1127    """
1128    A event filter class listening to key press events and responding
1129    by moving 'currentItem` on a :class:`QListView`.
1130
1131    """
1132    def __init__(self, parent=None):
1133        QObject.__init__(self, parent)
1134        self.__view = None
1135
1136    def setView(self, view):
1137        """
1138        Set the QListView.
1139        """
1140        if self.__view != view:
1141            self.__view = view
1142
1143    def view(self):
1144        """
1145        Return the view
1146        """
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):
1169        """
1170        Move the current index by rows, columns.
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):
1191        """
1192        Activate the current index.
1193        """
1194        if self.__view is not None:
1195            curr = self.__view.currentIndex()
1196            if curr.isValid():
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.
1201                self.__view.activated.emit(curr)
1202
1203    def ensureCurrent(self):
1204        """
1205        Ensure the view has a current item if one is available.
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
1216
1217
1218class WindowSizeGrip(QSizeGrip):
1219    """
1220    Automatically positioning :class:`QSizeGrip`.
1221    The widget automatically maintains its position in the window
1222    corner during resize events.
1223
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):
1234        """
1235        Set the corner (:class:`Qt.Corner`) where the size grip should
1236        position itself.
1237
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):
1248        """
1249        Return the corner where the size grip is positioned.
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)
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.