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

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

Implemented 'sloppy submenus' like handling of hover tab changes.

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                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)
656
657
658class PagedMenu(QWidget):
659    """
660    Tabbed container for :class:`MenuPage` instances.
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
673        layout = QHBoxLayout()
674        layout.setContentsMargins(0, 0, 0, 0)
675        layout.setSpacing(0)
676
677        self.__tab = TabBarWidget(self)
678        self.__tab.currentChanged.connect(self.setCurrentIndex)
679        self.__tab.setChangeOnHover(True)
680
681        self.__stack = MenuStackWidget(self)
682
683        layout.addWidget(self.__tab, alignment=Qt.AlignTop)
684        layout.addWidget(self.__stack)
685
686        self.setLayout(layout)
687
688    def addPage(self, page, title, icon=None, toolTip=None):
689        """
690        Add a `page` to the menu and return its index.
691        """
692        return self.insertPage(self.count(), page, title, icon, toolTip)
693
694    def insertPage(self, index, page, title, icon=None, toolTip=None):
695        """
696        Insert `page` at `index`.
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)
703        return index
704
705    def page(self, index):
706        """
707        Return the page at index.
708        """
709        return self.__stack.widget(index)
710
711    def removePage(self, index):
712        """
713        Remove the page at `index`.
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):
723        """
724        Return the number of pages.
725        """
726        return self.__stack.count()
727
728    def setCurrentIndex(self, index):
729        """
730        Set the current page index.
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):
739        """
740        Return the index of the current page.
741        """
742        return self.__currentIndex
743
744    def setCurrentPage(self, page):
745        """
746        Set `page` to be the current shown page.
747        """
748        index = self.__stack.indexOf(page)
749        self.setCurrentIndex(index)
750
751    def currentPage(self):
752        """
753        Return the current page.
754        """
755        return self.__stack.currentWidget()
756
757    def indexOf(self, page):
758        """
759        Return the index of `page`.
760        """
761        return self.__stack.indexOf(page)
762
763    def tabButton(self, index):
764        """
765        Return the tab button instance for index.
766        """
767        return self.__tab.button(index)
768
769
770class QuickMenu(FramelessWindow):
771    """
772    A quick menu popup for the widgets.
773
774    The widgets are set using :func:`QuickMenu.setModel` which must be a
775    model as returned by :func:`QtWidgetRegistry.model`
776
777    """
778
779    #: An action has been triggered in the menu.
780    triggered = Signal(QAction)
781
782    #: An action has been hovered in the menu
783    hovered = Signal(QAction)
784
785    def __init__(self, parent=None, **kwargs):
786        FramelessWindow.__init__(self, parent, **kwargs)
787        self.setWindowFlags(Qt.Popup)
788
789        self.__filterFunc = None
790
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
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
809        self.__frame = QFrame(self, objectName="menu-frame")
810        layout = QVBoxLayout()
811        layout.setContentsMargins(0, 0, 0, 0)
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
831        if sys.platform == "darwin":
832            view = self.__suggestPage.view()
833            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
834            # Don't show the focus frame because it expands into the tab bar.
835            view.setAttribute(Qt.WA_MacShowFocusRect, False)
836
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")
845
846        self.__search.textEdited.connect(self.__on_textEdited)
847
848        self.__navigator = ItemViewKeyNavigator(self)
849        self.__navigator.setView(self.__suggestPage.view())
850        self.__search.installEventFilter(self.__navigator)
851
852        self.__grip = WindowSizeGrip(self)
853        self.__grip.raise_()
854
855    def setSizeGripEnabled(self, enabled):
856        """
857        Enable the resizing of the menu with a size grip in a bottom
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):
870        """
871        Is the size grip enabled.
872        """
873        return bool(self.__grip)
874
875    def addPage(self, name, page):
876        """
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
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)
892
893        # Install event filter to intercept key presses.
894        page.view().installEventFilter(self)
895
896        return index
897
898    def createPage(self, index):
899        """
900        Create a new page based on the contents of an index
901        (:class:`QModeIndex`) item.
902
903        """
904        page = MenuPage(self)
905
906        page.setModel(index.model())
907        page.setRootIndex(index)
908
909        view = page.view()
910
911        if sys.platform == "darwin":
912            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
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)
916
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):
928        """
929        Set the model containing the actions.
930        """
931        root = model.invisibleRootItem()
932        for i in range(root.rowCount()):
933            item = root.child(i)
934            index = item.index()
935            page = self.createPage(index)
936            page.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE)
937            i = self.addPage(page.title(), page)
938
939            brush = index.data(QtWidgetRegistry.BACKGROUND_ROLE)
940
941            if brush.isValid():
942                brush = brush.toPyObject()
943                base_color = brush.color()
944                button = self.__pages.tabButton(i)
945                button.setStyleSheet(
946                    "TabButton {\n"
947                    "    qproperty-flat_: false;\n"
948                    "    background: %s;\n"
949                    "    border: none;\n"
950                    "    border-bottom: 1px solid palette(dark);\n"
951                    "}\n"
952                    "TabButton:checked {\n"
953                    "    background: %s\n"
954                    "}" % (create_css_gradient(base_color),
955                           create_css_gradient(base_color.darker(110)))
956                )
957
958        self.__model = model
959        self.__suggestPage.setModel(model)
960
961    def setFilterFunc(self, func):
962        """
963        Set a filter function.
964        """
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)
969
970    def popup(self, pos=None, searchText=""):
971        """
972        Popup the menu at `pos` (in screen coordinates). 'Search' text field
973        is initialized with `searchText` if provided.
974        """
975        if pos is None:
976            pos = QPoint()
977
978        self.__search.setText(searchText)
979        self.__suggestPage.setFilterFixedString(searchText)
980
981        self.ensurePolished()
982
983        if self.testAttribute(Qt.WA_Resized) and self.sizeGripEnabled():
984            size = self.size()
985        else:
986            size = self.sizeHint()
987
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
1021        if searchText:
1022            self.setFocusProxy(self.__search)
1023        else:
1024            self.setFocusProxy(None)
1025
1026    def exec_(self, pos=None, searchText=""):
1027        """
1028        Execute the menu at position `pos` (in global screen coordinates).
1029        Return the triggered :class:`QAction` or `None` if no action was
1030        triggered. 'Search' text field is initialized with `searchText` if
1031        provided.
1032
1033        """
1034        self.popup(pos, searchText)
1035        self.setFocus(Qt.PopupFocusReason)
1036
1037        self.__triggeredAction = None
1038        self.__loop = QEventLoop()
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):
1048        """
1049        Reimplemented from :class:`QWidget`
1050        """
1051        FramelessWindow.hideEvent(self, event)
1052        if self.__loop:
1053            self.__loop.exit()
1054
1055    def setCurrentPage(self, page):
1056        """
1057        Set the current shown page to `page`.
1058        """
1059        self.__pages.setCurrentPage(page)
1060
1061    def setCurrentIndex(self, index):
1062        """
1063        Set the current page index.
1064        """
1065        self.__pages.setCurrentIndex(index)
1066
1067    def __onTriggered(self, action):
1068        """
1069        Re-emit the action from the page.
1070        """
1071        self.__triggeredAction = action
1072
1073        # Hide and exit the event loop if necessary.
1074        self.hide()
1075        self.triggered.emit(action)
1076
1077    def __on_textEdited(self, text):
1078        self.__suggestPage.setFilterFixedString(text)
1079        self.__pages.setCurrentPage(self.__suggestPage)
1080
1081    def triggerSearch(self):
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        """
1087        self.__pages.setCurrentPage(self.__suggestPage)
1088        self.__search.setFocus(Qt.ShortcutFocusReason)
1089
1090        # Make sure that the first enabled item is set current.
1091        self.__suggestPage.ensureCurrent()
1092
1093    def keyPressEvent(self, event):
1094        if event.text():
1095            # Ignore modifiers, ...
1096            self.__search.setFocus(Qt.ShortcutFocusReason)
1097            self.setCurrentIndex(0)
1098            self.__search.keyPressEvent(event)
1099
1100        FramelessWindow.keyPressEvent(self, event)
1101        event.accept()
1102
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
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
1125class ItemViewKeyNavigator(QObject):
1126    """
1127    A event filter class listening to key press events and responding
1128    by moving 'currentItem` on a :class:`QListView`.
1129
1130    """
1131    def __init__(self, parent=None):
1132        QObject.__init__(self, parent)
1133        self.__view = None
1134
1135    def setView(self, view):
1136        """
1137        Set the QListView.
1138        """
1139        if self.__view != view:
1140            self.__view = view
1141
1142    def view(self):
1143        """
1144        Return the view
1145        """
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):
1168        """
1169        Move the current index by rows, columns.
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):
1190        """
1191        Activate the current index.
1192        """
1193        if self.__view is not None:
1194            curr = self.__view.currentIndex()
1195            if curr.isValid():
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.
1200                self.__view.activated.emit(curr)
1201
1202    def ensureCurrent(self):
1203        """
1204        Ensure the view has a current item if one is available.
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
1215
1216
1217class WindowSizeGrip(QSizeGrip):
1218    """
1219    Automatically positioning :class:`QSizeGrip`.
1220    The widget automatically maintains its position in the window
1221    corner during resize events.
1222
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):
1233        """
1234        Set the corner (:class:`Qt.Corner`) where the size grip should
1235        position itself.
1236
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):
1247        """
1248        Return the corner where the size grip is positioned.
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)
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.