source: orange/Orange/OrangeCanvas/document/quickmenu.py @ 11491:d8183bc8cf38

Revision 11491:d8183bc8cf38, 35.4 KB checked in by Ales Erjavec <ales.erjavec@…>, 12 months ago (diff)

Changed the tab widget in a quick menu.

The tab widget is now displayed vertically beside the menu items
and changes the current tab on hover.

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