source: orange/Orange/OrangeCanvas/document/quickmenu.py @ 11494:a7627a146d43

Revision 11494:a7627a146d43, 35.7 KB checked in by Ales Erjavec <ales.erjavec@…>, 12 months ago (diff)

Moved the size hint computation for the 'MenuPage'.

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