source: orange/Orange/OrangeCanvas/document/quickmenu.py @ 11523:8a3d9995ccd7

Revision 11523:8a3d9995ccd7, 38.4 KB checked in by Ales Erjavec <ales.erjavec@…>, 11 months ago (diff)

Selection follows mouse cursor in MenuPage widget.

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, QPolygon, QRegion, QItemSelectionModel
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.utils import StyledWidget_paintEvent, create_css_gradient
39
40from ..registry.qt import QtWidgetRegistry
41
42from ..resources import icon_loader
43
44log = logging.getLogger(__name__)
45
46
47class _MenuItemDelegate(QStyledItemDelegate):
48    def __init__(self, parent=None):
49        QStyledItemDelegate.__init__(self, parent)
50
51    def sizeHint(self, option, index):
52        option = QStyleOptionViewItemV4(option)
53        self.initStyleOption(option, index)
54        size = QStyledItemDelegate.sizeHint(self, option, index)
55
56        # TODO: get the default QMenu item height from the current style.
57        size.setHeight(max(size.height(), 25))
58        return size
59
60
61class MenuPage(ToolTree):
62    """
63    A menu page in a :class:`QuickMenu` widget, showing a list of actions.
64    Shown actions can be disabled by setting a filtering function using the
65    :func:`setFilterFunc`.
66
67    """
68    def __init__(self, parent=None, title=None, icon=None, **kwargs):
69        ToolTree.__init__(self, parent, **kwargs)
70
71        if title is None:
72            title = ""
73
74        if icon is None:
75            icon = QIcon()
76
77        self.__title = title
78        self.__icon = icon
79
80        self.view().setItemDelegate(_MenuItemDelegate(self.view()))
81        self.view().entered.connect(self.__onEntered)
82        self.view().viewport().setMouseTracking(True)
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        model = view.model()
157
158        # This will not work for nested items (tree).
159        count = model.rowCount(view.rootIndex())
160
161        width = view.sizeHintForColumn(0)
162
163        if count:
164            height = view.sizeHintForRow(0)
165            height = height * count
166        else:
167            height = 0
168        return QSize(width, height)
169
170    def __onEntered(self, index):
171        self.view().selectionModel().select(
172            index,
173            QItemSelectionModel.ClearAndSelect
174        )
175        self.view().setCurrentIndex(index)
176
177
178class ItemDisableFilter(QSortFilterProxyModel):
179    """
180    An filter proxy model used to disable selected items based on
181    a filtering function.
182
183    """
184    def __init__(self, parent=None):
185        QSortFilterProxyModel.__init__(self, parent)
186
187        self.__filterFunc = None
188
189    def setFilterFunc(self, func):
190        """
191        Set the filtering function.
192        """
193        if not (isinstance(func, Callable) or func is None):
194            raise ValueError("A callable object or None expected.")
195
196        if self.__filterFunc != func:
197            self.__filterFunc = func
198            # Mark the whole model as changed.
199            self.dataChanged.emit(self.index(0, 0),
200                                  self.index(self.rowCount(), 0))
201
202    def flags(self, index):
203        """
204        Reimplemented from :class:`QSortFilterProxyModel.flags`
205        """
206        source = self.mapToSource(index)
207        flags = source.flags()
208
209        if self.__filterFunc is not None:
210            enabled = flags & Qt.ItemIsEnabled
211            if enabled and not self.__filterFunc(source):
212                flags ^= Qt.ItemIsEnabled
213
214        return flags
215
216
217class SuggestMenuPage(MenuPage):
218    """
219    A MenuMage for the QuickMenu widget supporting item filtering
220    (searching).
221
222    """
223    def __init__(self, *args, **kwargs):
224        MenuPage.__init__(self, *args, **kwargs)
225
226    def setModel(self, model):
227        """
228        Reimplmemented from :ref:`MenuPage.setModel`.
229        """
230        flat = FlattenedTreeItemModel(self)
231        flat.setSourceModel(model)
232        flat.setFlatteningMode(flat.InternalNodesDisabled)
233        flat.setFlatteningMode(flat.LeavesOnly)
234        proxy = SortFilterProxyModel(self)
235        proxy.setFilterCaseSensitivity(False)
236        proxy.setSourceModel(flat)
237        ToolTree.setModel(self, proxy)
238        self.ensureCurrent()
239
240    def setFilterFixedString(self, pattern):
241        """
242        Set the fixed string filtering pattern. Only items which contain the
243        `pattern` string will be shown.
244
245        """
246        proxy = self.view().model()
247        proxy.setFilterFixedString(pattern)
248        self.ensureCurrent()
249
250    def setFilterRegExp(self, pattern):
251        """
252        Set the regular expression filtering pattern. Only items matching
253        the `pattern` expression will be shown.
254
255        """
256        filter_proxy = self.view().model()
257        filter_proxy.setFilterRegExp(pattern)
258        self.ensureCurrent()
259
260    def setFilterWildCard(self, pattern):
261        """
262        Set a wildcard filtering pattern.
263        """
264        filter_proxy = self.view().model()
265        filter_proxy.setFilterWildCard(pattern)
266        self.ensureCurrent()
267
268    def setFilterFunc(self, func):
269        """
270        Set a filtering function.
271        """
272        filter_proxy = self.view().model()
273        filter_proxy.setFilterFunc(func)
274
275
276class SortFilterProxyModel(QSortFilterProxyModel):
277    """
278    An filter proxy model used to filter items based on a filtering
279    function.
280
281    """
282    def __init__(self, parent=None):
283        QSortFilterProxyModel.__init__(self, parent)
284
285        self.__filterFunc = None
286
287    def setFilterFunc(self, func):
288        """
289        Set the filtering function.
290        """
291        if not (isinstance(func, Callable) or func is None):
292            raise ValueError("A callable object or None expected.")
293
294        if self.__filterFunc is not func:
295            self.__filterFunc = func
296            self.invalidateFilter()
297
298    def filterFunc(self):
299        return self.__filterFunc
300
301    def filterAcceptsRow(self, row, parent=QModelIndex()):
302        accepted = QSortFilterProxyModel.filterAcceptsRow(self, row, parent)
303        if accepted and self.__filterFunc is not None:
304            model = self.sourceModel()
305            index = model.index(row, self.filterKeyColumn(), parent)
306            return self.__filterFunc(index)
307        else:
308            return accepted
309
310
311class SearchWidget(LineEdit):
312    def __init__(self, parent=None, **kwargs):
313        LineEdit.__init__(self, parent, **kwargs)
314        self.__setupUi()
315
316    def __setupUi(self):
317        icon = icon_loader().get("icons/Search.svg")
318        action = QAction(icon, "Search", self)
319        self.setAction(action, LineEdit.LeftPosition)
320
321
322class MenuStackWidget(QStackedWidget):
323    """
324    Stack widget for the menu pages.
325    """
326
327    def sizeHint(self):
328        """
329        Size hint is the maximum width and median height of the widgets
330        contained in the stack.
331
332        """
333        default_size = QSize(200, 400)
334        widget_hints = [default_size]
335        for i in range(self.count()):
336            hint = self.widget(i).sizeHint()
337            widget_hints.append(hint)
338
339        width = max([s.width() for s in widget_hints])
340        # Take the median for the height
341        height = numpy.median([s.height() for s in widget_hints])
342
343        return QSize(width, int(height))
344
345    def __sizeHintForTreeView(self, view):
346        hint = view.sizeHint()
347        model = view.model()
348
349        count = model.rowCount()
350        width = view.sizeHintForColumn(0)
351
352        if count:
353            height = view.sizeHintForRow(0)
354            height = height * count
355        else:
356            height = hint.height()
357
358        return QSize(max(width, hint.width()), max(height, hint.height()))
359
360
361class TabButton(QToolButton):
362    def __init__(self, parent=None, **kwargs):
363        QToolButton.__init__(self, parent, **kwargs)
364        self.setToolButtonStyle(Qt.ToolButtonIconOnly)
365        self.setCheckable(True)
366
367        self.__flat = True
368        self.__showMenuIndicator = False
369
370    def setFlat(self, flat):
371        if self.__flat != flat:
372            self.__flat = flat
373            self.update()
374
375    def flat(self):
376        return self.__flat
377
378    flat_ = Property(bool, fget=flat, fset=setFlat,
379                     designable=True)
380
381    def setShownMenuIndicator(self, show):
382        if self.__showMenuIndicator != show:
383            self.__showMenuIndicator = show
384            self.update()
385
386    def showMenuIndicator(self):
387        return self.__showMenuIndicator
388
389    showMenuIndicator_ = Property(bool, fget=showMenuIndicator,
390                                  fset=setShownMenuIndicator,
391                                  designable=True)
392
393    def paintEvent(self, event):
394        opt = QStyleOptionToolButton()
395        self.initStyleOption(opt)
396        if self.__showMenuIndicator and self.isChecked():
397            opt.features |= QStyleOptionToolButton.HasMenu
398        if self.__flat:
399            # Use default widget background/border styling.
400            StyledWidget_paintEvent(self, event)
401
402            p = QStylePainter(self)
403            p.drawControl(QStyle.CE_ToolButtonLabel, opt)
404        else:
405            p = QStylePainter(self)
406            p.drawComplexControl(QStyle.CC_ToolButton, opt)
407
408    def sizeHint(self):
409        opt = QStyleOptionToolButton()
410        self.initStyleOption(opt)
411        if self.__showMenuIndicator and self.isChecked():
412            opt.features |= QStyleOptionToolButton.HasMenu
413        style = self.style()
414
415        hint = style.sizeFromContents(QStyle.CT_ToolButton, opt,
416                                      opt.iconSize, self)
417        return hint
418
419_Tab = \
420    namedtuple(
421        "_Tab",
422        ["text",
423         "icon",
424         "toolTip",
425         "button",
426         "data",
427         "palette"])
428
429
430class TabBarWidget(QWidget):
431    """
432    A tab bar widget using tool buttons as tabs.
433    """
434    # TODO: A uniform size box layout.
435
436    currentChanged = Signal(int)
437
438    def __init__(self, parent=None, **kwargs):
439        QWidget.__init__(self, parent, **kwargs)
440        layout = QVBoxLayout()
441        layout.setContentsMargins(0, 0, 0, 0)
442        layout.setSpacing(0)
443        self.setLayout(layout)
444
445        self.setSizePolicy(QSizePolicy.Fixed,
446                           QSizePolicy.Expanding)
447        self.__tabs = []
448
449        self.__currentIndex = -1
450        self.__changeOnHover = False
451
452        self.__iconSize = QSize(26, 26)
453
454        self.__group = QButtonGroup(self, exclusive=True)
455        self.__group.buttonPressed[QAbstractButton].connect(
456            self.__onButtonPressed
457        )
458        self.setMouseTracking(True)
459
460        self.__sloppyButton = None
461        self.__sloppyRegion = QRegion()
462        self.__sloppyTimer = QTimer(self, singleShot=True)
463        self.__sloppyTimer.timeout.connect(self.__onSloppyTimeout)
464
465    def setChangeOnHover(self, changeOnHover):
466        """
467        If set to ``True`` the tab widget will change the current index when
468        the mouse hovers over a tab button.
469
470        """
471        if self.__changeOnHover != changeOnHover:
472            self.__changeOnHover = changeOnHover
473
474    def changeOnHover(self):
475        """
476        Does the current tab index follow the mouse cursor.
477        """
478        return self.__changeOnHover
479
480    def count(self):
481        """
482        Return the number of tabs in the widget.
483        """
484        return len(self.__tabs)
485
486    def addTab(self, text, icon=None, toolTip=None):
487        """
488        Add a new tab and return it's index.
489        """
490        return self.insertTab(self.count(), text, icon, toolTip)
491
492    def insertTab(self, index, text, icon=None, toolTip=None):
493        """
494        Insert a tab at `index`
495        """
496        button = TabButton(self, objectName="tab-button")
497        button.setSizePolicy(QSizePolicy.Expanding,
498                             QSizePolicy.Expanding)
499        button.setIconSize(self.__iconSize)
500        button.setMouseTracking(True)
501
502        self.__group.addButton(button)
503
504        button.installEventFilter(self)
505
506        tab = _Tab(text, icon, toolTip, button, None, None)
507        self.layout().insertWidget(index, button)
508
509        self.__tabs.insert(index, tab)
510        self.__updateTab(index)
511
512        if self.currentIndex() == -1:
513            self.setCurrentIndex(0)
514        return index
515
516    def removeTab(self, index):
517        """
518        Remove a tab at `index`.
519        """
520        if index >= 0 and index < self.count():
521            self.layout().takeItem(index)
522            tab = self.__tabs.pop(index)
523            self.__group.removeButton(tab.button)
524
525            tab.button.removeEventFilter(self)
526
527            if tab.button is self.__sloppyButton:
528                self.__sloppyButton = None
529                self.__sloppyRegion = QRegion()
530
531            tab.button.deleteLater()
532
533            if self.currentIndex() == index:
534                if self.count():
535                    self.setCurrentIndex(max(index - 1, 0))
536                else:
537                    self.setCurrentIndex(-1)
538
539    def setTabIcon(self, index, icon):
540        """
541        Set the `icon` for tab at `index`.
542        """
543        self.__tabs[index] = self.__tabs[index]._replace(icon=icon)
544        self.__updateTab(index)
545
546    def setTabToolTip(self, index, toolTip):
547        """
548        Set `toolTip` for tab at `index`.
549        """
550        self.__tabs[index] = self.__tabs[index]._replace(toolTip=toolTip)
551        self.__updateTab(index)
552
553    def setTabText(self, index, text):
554        """
555        Set tab `text` for tab at `index`
556        """
557        self.__tabs[index] = self.__tabs[index]._replace(text=text)
558        self.__updateTab(index)
559
560    def setTabPalette(self, index, palette):
561        """
562        Set the tab button palette.
563        """
564        self.__tabs[index] = self.__tabs[index]._replace(palette=palette)
565        self.__updateTab(index)
566
567    def setCurrentIndex(self, index):
568        """
569        Set the current tab index.
570        """
571        if self.__currentIndex != index:
572            self.__currentIndex = index
573
574            self.__sloppyRegion = QRegion()
575            self.__sloppyButton = None
576
577            if index != -1:
578                self.__tabs[index].button.setChecked(True)
579
580            self.currentChanged.emit(index)
581
582    def currentIndex(self):
583        """
584        Return the current index.
585        """
586        return self.__currentIndex
587
588    def button(self, index):
589        """
590        Return the `TabButton` instance for index.
591        """
592        return self.__tabs[index].button
593
594    def setIconSize(self, size):
595        if self.__iconSize != size:
596            self.__iconSize = size
597            for tab in self.__tabs:
598                tab.button.setIconSize(self.__iconSize)
599
600    def __updateTab(self, index):
601        """
602        Update the tab button.
603        """
604        tab = self.__tabs[index]
605        b = tab.button
606
607        if tab.text:
608            b.setText(tab.text)
609
610        if tab.icon is not None and not tab.icon.isNull():
611            b.setIcon(tab.icon)
612
613        if tab.palette:
614            b.setPalette(tab.palette)
615
616    def __onButtonPressed(self, button):
617        for i, tab in enumerate(self.__tabs):
618            if tab.button is button:
619                self.setCurrentIndex(i)
620                break
621
622    def __calcSloppyRegion(self, current):
623        """
624        Given a current mouse cursor position return a region of the widget
625        where hover/move events should change the current tab only on a
626        timeout.
627
628        """
629        p1 = current + QPoint(0, 2)
630        p2 = current + QPoint(0, -2)
631        p3 = self.pos() + QPoint(self.width()+10, 0)
632        p4 = self.pos() + QPoint(self.width()+10, self.height())
633        return QRegion(QPolygon([p1, p2, p3, p4]))
634
635    def __setSloppyButton(self, button):
636        """
637        Set the current sloppy button (a tab button inside sloppy region)
638        and reset the sloppy timeout.
639
640        """
641        if not button.isChecked():
642            self.__sloppyButton = button
643            delay = self.style().styleHint(QStyle.SH_Menu_SubMenuPopupDelay, None)
644            # The delay timeout is the same as used by Qt in the QMenu.
645            self.__sloppyTimer.start(delay)
646        else:
647            self.__sloppyTimer.stop()
648
649    def __onSloppyTimeout(self):
650        if self.__sloppyButton is not None:
651            button = self.__sloppyButton
652            self.__sloppyButton = None
653            if not button.isChecked():
654                index = [tab.button for tab in self.__tabs].index(button)
655                self.setCurrentIndex(index)
656
657    def eventFilter(self, receiver, event):
658        if event.type() == QEvent.MouseMove and \
659                isinstance(receiver, TabButton):
660            pos = receiver.mapTo(self, event.pos())
661            if self.__sloppyRegion.contains(pos):
662                self.__setSloppyButton(receiver)
663            else:
664                if not receiver.isChecked():
665                    index = [tab.button for tab in self.__tabs].index(receiver)
666                    self.setCurrentIndex(index)
667                #also update sloppy region if mouse is moved on the same icon
668                self.__sloppyRegion = self.__calcSloppyRegion(pos)
669
670        return QWidget.eventFilter(self, receiver, event)
671
672    def leaveEvent(self, event):
673        self.__sloppyButton = None
674        self.__sloppyRegion = QRegion()
675
676        return QWidget.leaveEvent(self, event)
677
678
679class PagedMenu(QWidget):
680    """
681    Tabbed container for :class:`MenuPage` instances.
682    """
683    triggered = Signal(QAction)
684    hovered = Signal(QAction)
685
686    currentChanged = Signal(int)
687
688    def __init__(self, parent=None, **kwargs):
689        QWidget.__init__(self, parent, **kwargs)
690
691        self.__pages = []
692        self.__currentIndex = -1
693
694        layout = QHBoxLayout()
695        layout.setContentsMargins(0, 0, 0, 0)
696        layout.setSpacing(6)
697
698        self.__tab = TabBarWidget(self)
699        self.__tab.currentChanged.connect(self.setCurrentIndex)
700        self.__tab.setChangeOnHover(True)
701
702        self.__stack = MenuStackWidget(self)
703
704        layout.addWidget(self.__tab, alignment=Qt.AlignTop)
705        layout.addWidget(self.__stack)
706
707        self.setLayout(layout)
708
709    def addPage(self, page, title, icon=None, toolTip=None):
710        """
711        Add a `page` to the menu and return its index.
712        """
713        return self.insertPage(self.count(), page, title, icon, toolTip)
714
715    def insertPage(self, index, page, title, icon=None, toolTip=None):
716        """
717        Insert `page` at `index`.
718        """
719        page.triggered.connect(self.triggered)
720        page.hovered.connect(self.hovered)
721
722        self.__stack.insertWidget(index, page)
723        self.__tab.insertTab(index, title, icon, toolTip)
724        return index
725
726    def page(self, index):
727        """
728        Return the page at index.
729        """
730        return self.__stack.widget(index)
731
732    def removePage(self, index):
733        """
734        Remove the page at `index`.
735        """
736        page = self.__stack.widget(index)
737        page.triggered.disconnect(self.triggered)
738        page.hovered.disconnect(self.hovered)
739
740        self.__stack.removeWidget(page)
741        self.__tab.removeTab(index)
742
743    def count(self):
744        """
745        Return the number of pages.
746        """
747        return self.__stack.count()
748
749    def setCurrentIndex(self, index):
750        """
751        Set the current page index.
752        """
753        if self.__currentIndex != index:
754            self.__currentIndex = index
755            self.__tab.setCurrentIndex(index)
756            self.__stack.setCurrentIndex(index)
757            self.currentChanged.emit(index)
758
759    def currentIndex(self):
760        """
761        Return the index of the current page.
762        """
763        return self.__currentIndex
764
765    def setCurrentPage(self, page):
766        """
767        Set `page` to be the current shown page.
768        """
769        index = self.__stack.indexOf(page)
770        self.setCurrentIndex(index)
771
772    def currentPage(self):
773        """
774        Return the current page.
775        """
776        return self.__stack.currentWidget()
777
778    def indexOf(self, page):
779        """
780        Return the index of `page`.
781        """
782        return self.__stack.indexOf(page)
783
784    def tabButton(self, index):
785        """
786        Return the tab button instance for index.
787        """
788        return self.__tab.button(index)
789
790
791TAB_BUTTON_STYLE_TEMPLATE = """\
792TabButton {
793    qproperty-flat_: false;
794    background: %s;
795    border: none;
796    border-bottom: 1px solid palette(mid);
797}
798
799TabButton:checked {
800    background: %s;
801    border: none;
802    border-top: 1px solid #609ED7;
803    border-bottom: 1px solid #609ED7;
804}
805"""
806
807
808class QuickMenu(FramelessWindow):
809    """
810    A quick menu popup for the widgets.
811
812    The widgets are set using :func:`QuickMenu.setModel` which must be a
813    model as returned by :func:`QtWidgetRegistry.model`
814
815    """
816
817    #: An action has been triggered in the menu.
818    triggered = Signal(QAction)
819
820    #: An action has been hovered in the menu
821    hovered = Signal(QAction)
822
823    def __init__(self, parent=None, **kwargs):
824        FramelessWindow.__init__(self, parent, **kwargs)
825        self.setWindowFlags(Qt.Popup)
826
827        self.__filterFunc = None
828
829        self.__setupUi()
830
831        self.__loop = None
832        self.__model = QStandardItemModel()
833        self.__triggeredAction = None
834
835    def __setupUi(self):
836        self.setLayout(QVBoxLayout(self))
837        self.layout().setContentsMargins(6, 6, 6, 6)
838
839        self.__search = SearchWidget(self, objectName="search-line")
840
841        self.__search.setPlaceholderText(
842            self.tr("Search for widget or select from the list.")
843        )
844
845        self.layout().addWidget(self.__search)
846
847        self.__frame = QFrame(self, objectName="menu-frame")
848        layout = QVBoxLayout()
849        layout.setContentsMargins(0, 0, 0, 0)
850        layout.setSpacing(2)
851        self.__frame.setLayout(layout)
852
853        self.layout().addWidget(self.__frame)
854
855        self.__pages = PagedMenu(self, objectName="paged-menu")
856        self.__pages.currentChanged.connect(self.setCurrentIndex)
857        self.__pages.triggered.connect(self.triggered)
858        self.__pages.hovered.connect(self.hovered)
859
860        self.__frame.layout().addWidget(self.__pages)
861
862        self.setSizePolicy(QSizePolicy.Fixed,
863                           QSizePolicy.Expanding)
864
865        self.__suggestPage = SuggestMenuPage(self, objectName="suggest-page")
866        self.__suggestPage.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE)
867        self.__suggestPage.setIcon(icon_loader().get("icons/Search.svg"))
868
869        if sys.platform == "darwin":
870            view = self.__suggestPage.view()
871            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
872            # Don't show the focus frame because it expands into the tab bar.
873            view.setAttribute(Qt.WA_MacShowFocusRect, False)
874
875        i = self.addPage(self.tr("Quick Search"), self.__suggestPage)
876        button = self.__pages.tabButton(i)
877        button.setObjectName("search-tab-button")
878        button.setStyleSheet(
879            "TabButton {\n"
880            "    qproperty-flat_: false;\n"
881            "    border: none;"
882            "}\n")
883
884        self.__search.textEdited.connect(self.__on_textEdited)
885
886        self.__navigator = ItemViewKeyNavigator(self)
887        self.__navigator.setView(self.__suggestPage.view())
888        self.__search.installEventFilter(self.__navigator)
889
890        self.__grip = WindowSizeGrip(self)
891        self.__grip.raise_()
892
893    def setSizeGripEnabled(self, enabled):
894        """
895        Enable the resizing of the menu with a size grip in a bottom
896        right corner (enabled by default).
897
898        """
899        if bool(enabled) != bool(self.__grip):
900            if self.__grip:
901                self.__grip.deleteLater()
902                self.__grip = None
903            else:
904                self.__grip = WindowSizeGrip(self)
905                self.__grip.raise_()
906
907    def sizeGripEnabled(self):
908        """
909        Is the size grip enabled.
910        """
911        return bool(self.__grip)
912
913    def addPage(self, name, page):
914        """
915        Add the `page` (:class:`MenuPage`) with `name` and return it's index.
916        The `page.icon()` will be used as the icon in the tab bar.
917
918        """
919        icon = page.icon()
920
921        tip = name
922        if page.toolTip():
923            tip = page.toolTip()
924
925        index = self.__pages.addPage(page, name, icon, tip)
926
927        # Route the page's signals
928        page.triggered.connect(self.__onTriggered)
929        page.hovered.connect(self.hovered)
930
931        # Install event filter to intercept key presses.
932        page.view().installEventFilter(self)
933
934        return index
935
936    def createPage(self, index):
937        """
938        Create a new page based on the contents of an index
939        (:class:`QModeIndex`) item.
940
941        """
942        page = MenuPage(self)
943
944        page.setModel(index.model())
945        page.setRootIndex(index)
946
947        view = page.view()
948
949        if sys.platform == "darwin":
950            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
951            # Don't show the focus frame because it expands into the tab
952            # bar at the top.
953            view.setAttribute(Qt.WA_MacShowFocusRect, False)
954
955        name = unicode(index.data(Qt.DisplayRole))
956        page.setTitle(name)
957
958        icon = index.data(Qt.DecorationRole).toPyObject()
959        if isinstance(icon, QIcon):
960            page.setIcon(icon)
961
962        page.setToolTip(index.data(Qt.ToolTipRole).toPyObject())
963        return page
964
965    def setModel(self, model):
966        """
967        Set the model containing the actions.
968        """
969        root = model.invisibleRootItem()
970        for i in range(root.rowCount()):
971            item = root.child(i)
972            index = item.index()
973            page = self.createPage(index)
974            page.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE)
975            i = self.addPage(page.title(), page)
976
977            brush = index.data(QtWidgetRegistry.BACKGROUND_ROLE)
978
979            if brush.isValid():
980                brush = brush.toPyObject()
981                base_color = brush.color()
982                button = self.__pages.tabButton(i)
983                button.setStyleSheet(
984                    TAB_BUTTON_STYLE_TEMPLATE %
985                    (create_css_gradient(base_color),
986                     create_css_gradient(base_color.darker(120)))
987                )
988
989        self.__model = model
990        self.__suggestPage.setModel(model)
991
992    def setFilterFunc(self, func):
993        """
994        Set a filter function.
995        """
996        if func != self.__filterFunc:
997            self.__filterFunc = func
998            for i in range(0, self.__pages.count()):
999                self.__pages.page(i).setFilterFunc(func)
1000
1001    def popup(self, pos=None, searchText=""):
1002        """
1003        Popup the menu at `pos` (in screen coordinates). 'Search' text field
1004        is initialized with `searchText` if provided.
1005
1006        """
1007        if pos is None:
1008            pos = QPoint()
1009
1010        self.__clearCurrentItems()
1011
1012        self.__search.setText(searchText)
1013        self.__suggestPage.setFilterFixedString(searchText)
1014
1015        self.ensurePolished()
1016
1017        if self.testAttribute(Qt.WA_Resized) and self.sizeGripEnabled():
1018            size = self.size()
1019        else:
1020            size = self.sizeHint()
1021
1022        desktop = QApplication.desktop()
1023        screen_geom = desktop.availableGeometry(pos)
1024
1025        # Adjust the size to fit inside the screen.
1026        if size.height() > screen_geom.height():
1027            size.setHeight(screen_geom.height())
1028        if size.width() > screen_geom.width():
1029            size.setWidth(screen_geom.width())
1030
1031        geom = QRect(pos, size)
1032
1033        if geom.top() < screen_geom.top():
1034            geom.setTop(screen_geom.top())
1035
1036        if geom.left() < screen_geom.left():
1037            geom.setLeft(screen_geom.left())
1038
1039        bottom_margin = screen_geom.bottom() - geom.bottom()
1040        right_margin = screen_geom.right() - geom.right()
1041        if bottom_margin < 0:
1042            # Falls over the bottom of the screen, move it up.
1043            geom.translate(0, bottom_margin)
1044
1045        # TODO: right to left locale
1046        if right_margin < 0:
1047            # Falls over the right screen edge, move the menu to the
1048            # other side of pos.
1049            geom.translate(-size.width(), 0)
1050
1051        self.setGeometry(geom)
1052
1053        self.show()
1054
1055        if searchText:
1056            self.setFocusProxy(self.__search)
1057        else:
1058            self.setFocusProxy(None)
1059
1060    def exec_(self, pos=None, searchText=""):
1061        """
1062        Execute the menu at position `pos` (in global screen coordinates).
1063        Return the triggered :class:`QAction` or `None` if no action was
1064        triggered. 'Search' text field is initialized with `searchText` if
1065        provided.
1066
1067        """
1068        self.popup(pos, searchText)
1069        self.setFocus(Qt.PopupFocusReason)
1070
1071        self.__triggeredAction = None
1072        self.__loop = QEventLoop()
1073        self.__loop.exec_()
1074        self.__loop.deleteLater()
1075        self.__loop = None
1076
1077        action = self.__triggeredAction
1078        self.__triggeredAction = None
1079        return action
1080
1081    def hideEvent(self, event):
1082        """
1083        Reimplemented from :class:`QWidget`
1084        """
1085        FramelessWindow.hideEvent(self, event)
1086        if self.__loop:
1087            self.__loop.exit()
1088
1089    def setCurrentPage(self, page):
1090        """
1091        Set the current shown page to `page`.
1092        """
1093        self.__pages.setCurrentPage(page)
1094
1095    def setCurrentIndex(self, index):
1096        """
1097        Set the current page index.
1098        """
1099        self.__pages.setCurrentIndex(index)
1100
1101    def __clearCurrentItems(self):
1102        """
1103        Clear any selected (or current) items in all the menus.
1104        """
1105        for i in range(self.__pages.count()):
1106            self.__pages.page(i).view().selectionModel().clear()
1107
1108    def __onTriggered(self, action):
1109        """
1110        Re-emit the action from the page.
1111        """
1112        self.__triggeredAction = action
1113
1114        # Hide and exit the event loop if necessary.
1115        self.hide()
1116        self.triggered.emit(action)
1117
1118    def __on_textEdited(self, text):
1119        self.__suggestPage.setFilterFixedString(text)
1120        self.__pages.setCurrentPage(self.__suggestPage)
1121
1122    def triggerSearch(self):
1123        """
1124        Trigger action search. This changes to current page to the
1125        'Suggest' page and sets the keyboard focus to the search line edit.
1126
1127        """
1128        self.__pages.setCurrentPage(self.__suggestPage)
1129        self.__search.setFocus(Qt.ShortcutFocusReason)
1130
1131        # Make sure that the first enabled item is set current.
1132        self.__suggestPage.ensureCurrent()
1133
1134    def keyPressEvent(self, event):
1135        if event.text():
1136            # Ignore modifiers, ...
1137            self.__search.setFocus(Qt.ShortcutFocusReason)
1138            self.setCurrentIndex(0)
1139            self.__search.keyPressEvent(event)
1140
1141        FramelessWindow.keyPressEvent(self, event)
1142        event.accept()
1143
1144    def event(self, event):
1145        if event.type() == QEvent.ShortcutOverride:
1146            log.debug("Overriding shortcuts")
1147            event.accept()
1148            return True
1149        return FramelessWindow.event(self, event)
1150
1151    def eventFilter(self, obj, event):
1152        if isinstance(obj, QTreeView):
1153            etype = event.type()
1154            if etype == QEvent.KeyPress:
1155                # ignore modifiers non printable characters, Enter, ...
1156                if event.text() and event.key() not in \
1157                        [Qt.Key_Enter, Qt.Key_Return]:
1158                    self.__search.setFocus(Qt.ShortcutFocusReason)
1159                    self.setCurrentIndex(0)
1160                    self.__search.keyPressEvent(event)
1161                    return True
1162
1163        return FramelessWindow.eventFilter(self, obj, event)
1164
1165
1166class ItemViewKeyNavigator(QObject):
1167    """
1168    A event filter class listening to key press events and responding
1169    by moving 'currentItem` on a :class:`QListView`.
1170
1171    """
1172    def __init__(self, parent=None):
1173        QObject.__init__(self, parent)
1174        self.__view = None
1175
1176    def setView(self, view):
1177        """
1178        Set the QListView.
1179        """
1180        if self.__view != view:
1181            self.__view = view
1182
1183    def view(self):
1184        """
1185        Return the view
1186        """
1187        return self.__view
1188
1189    def eventFilter(self, obj, event):
1190        etype = event.type()
1191        if etype == QEvent.KeyPress:
1192            key = event.key()
1193            if key == Qt.Key_Down:
1194                self.moveCurrent(1, 0)
1195                return True
1196            elif key == Qt.Key_Up:
1197                self.moveCurrent(-1, 0)
1198                return True
1199            elif key == Qt.Key_Tab:
1200                self.moveCurrent(0, 1)
1201                return  True
1202            elif key == Qt.Key_Enter or key == Qt.Key_Return:
1203                self.activateCurrent()
1204                return True
1205
1206        return QObject.eventFilter(self, obj, event)
1207
1208    def moveCurrent(self, rows, columns=0):
1209        """
1210        Move the current index by rows, columns.
1211        """
1212        if self.__view is not None:
1213            view = self.__view
1214            model = view.model()
1215
1216            curr = view.currentIndex()
1217            curr_row, curr_col = curr.row(), curr.column()
1218
1219            sign = 1 if rows >= 0 else -1
1220            row = curr_row + rows
1221
1222            row_count = model.rowCount()
1223            for i in range(row_count):
1224                index = model.index((row + sign * i) % row_count, 0)
1225                if index.flags() & Qt.ItemIsEnabled:
1226                    view.setCurrentIndex(index)
1227                    break
1228            # TODO: move by columns
1229
1230    def activateCurrent(self):
1231        """
1232        Activate the current index.
1233        """
1234        if self.__view is not None:
1235            curr = self.__view.currentIndex()
1236            if curr.isValid():
1237                # TODO: Does this work? We are emitting signals that are
1238                # defined by a different class. This might break some things.
1239                # Should we just send the keyPress events to the view, and let
1240                # it handle them.
1241                self.__view.activated.emit(curr)
1242
1243    def ensureCurrent(self):
1244        """
1245        Ensure the view has a current item if one is available.
1246        """
1247        if self.__view is not None:
1248            model = self.__view.model()
1249            curr = self.__view.currentIndex()
1250            if not curr.isValid():
1251                for i in range(model.rowCount()):
1252                    index = model.index(i, 0)
1253                    if index.flags() & Qt.ItemIsEnabled:
1254                        self.__view.setCurrentIndex(index)
1255                        break
1256
1257
1258class WindowSizeGrip(QSizeGrip):
1259    """
1260    Automatically positioning :class:`QSizeGrip`.
1261    The widget automatically maintains its position in the window
1262    corner during resize events.
1263
1264    """
1265    def __init__(self, parent):
1266        QSizeGrip.__init__(self, parent)
1267        self.__corner = Qt.BottomRightCorner
1268
1269        self.resize(self.sizeHint())
1270
1271        self.__updatePos()
1272
1273    def setCorner(self, corner):
1274        """
1275        Set the corner (:class:`Qt.Corner`) where the size grip should
1276        position itself.
1277
1278        """
1279        if corner not in [Qt.TopLeftCorner, Qt.TopRightCorner,
1280                          Qt.BottomLeftCorner, Qt.BottomRightCorner]:
1281            raise ValueError("Qt.Corner flag expected")
1282
1283        if self.__corner != corner:
1284            self.__corner = corner
1285            self.__updatePos()
1286
1287    def corner(self):
1288        """
1289        Return the corner where the size grip is positioned.
1290        """
1291        return self.__corner
1292
1293    def eventFilter(self, obj, event):
1294        if obj is self.window():
1295            if event.type() == QEvent.Resize:
1296                self.__updatePos()
1297
1298        return QSizeGrip.eventFilter(self, obj, event)
1299
1300    def showEvent(self, event):
1301        if self.window() != self.parent():
1302            log.error("%s: Can only show on a top level window.",
1303                      type(self).__name__)
1304
1305        return QSizeGrip.showEvent(self, event)
1306
1307    def __updatePos(self):
1308        window = self.window()
1309
1310        if window is not self.parent():
1311            return
1312
1313        corner = self.__corner
1314        size = self.sizeHint()
1315
1316        window_geom = window.geometry()
1317        window_size = window_geom.size()
1318
1319        if corner in [Qt.TopLeftCorner, Qt.BottomLeftCorner]:
1320            x = 0
1321        else:
1322            x = window_geom.width() - size.width()
1323
1324        if corner in [Qt.TopLeftCorner, Qt.TopRightCorner]:
1325            y = 0
1326        else:
1327            y = window_size.height() - size.height()
1328
1329        self.move(x, y)
Note: See TracBrowser for help on using the repository browser.