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

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

Moved the size hint computation for the 'MenuPage'.

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
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)
32
33
34from ..gui.framelesswindow import FramelessWindow
35from ..gui.lineedit import LineEdit
36from ..gui.tooltree import ToolTree, FlattenedTreeItemModel
37from ..gui.toolgrid import ToolButtonEventListener
38from ..gui.toolbox import create_tab_gradient
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
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
62class MenuPage(ToolTree):
63    """
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
66    :func:`setFilterFunc`.
67
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
81        self.view().setItemDelegate(_MenuItemDelegate(self.view()))
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
99    title_ = Property(unicode, fget=title, fset=setTitle,
100                      doc="Title of the page.")
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
116    icon_ = Property(QIcon, fget=icon, fset=setIcon,
117                     doc="Page icon")
118
119    def setFilterFunc(self, func):
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        """
127        proxyModel = self.view().model()
128        proxyModel.setFilterFunc(func)
129
130    def setModel(self, model):
131        """
132        Reimplemented from :func:`ToolTree.setModel`.
133        """
134        proxyModel = ItemDisableFilter(self)
135        proxyModel.setSourceModel(model)
136        ToolTree.setModel(self, proxyModel)
137
138    def setRootIndex(self, index):
139        """
140        Reimplemented from :func:`ToolTree.setRootIndex`
141        """
142        proxyModel = self.view().model()
143        mappedIndex = proxyModel.mapFromSource(index)
144        ToolTree.setRootIndex(self, mappedIndex)
145
146    def rootIndex(self):
147        """
148        Reimplemented from :func:`ToolTree.rootIndex`
149        """
150        proxyModel = self.view().model()
151        return proxyModel.mapToSource(ToolTree.rootIndex(self))
152
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
170
171class ItemDisableFilter(QSortFilterProxyModel):
172    """
173    An filter proxy model used to disable selected items based on
174    a filtering function.
175
176    """
177    def __init__(self, parent=None):
178        QSortFilterProxyModel.__init__(self, parent)
179
180        self.__filterFunc = None
181
182    def setFilterFunc(self, func):
183        """
184        Set the filtering function.
185        """
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):
196        """
197        Reimplemented from :class:`QSortFilterProxyModel.flags`
198        """
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):
211    """
212    A MenuMage for the QuickMenu widget supporting item filtering
213    (searching).
214
215    """
216    def __init__(self, *args, **kwargs):
217        MenuPage.__init__(self, *args, **kwargs)
218
219    def setModel(self, model):
220        """
221        Reimplmemented from :ref:`MenuPage.setModel`.
222        """
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):
234        """
235        Set the fixed string filtering pattern. Only items which contain the
236        `pattern` string will be shown.
237
238        """
239        proxy = self.view().model()
240        proxy.setFilterFixedString(pattern)
241        self.ensureCurrent()
242
243    def setFilterRegExp(self, pattern):
244        """
245        Set the regular expression filtering pattern. Only items matching
246        the `pattern` expression will be shown.
247
248        """
249        filter_proxy = self.view().model()
250        filter_proxy.setFilterRegExp(pattern)
251        self.ensureCurrent()
252
253    def setFilterWildCard(self, pattern):
254        """
255        Set a wildcard filtering pattern.
256        """
257        filter_proxy = self.view().model()
258        filter_proxy.setFilterWildCard(pattern)
259        self.ensureCurrent()
260
261    def setFilterFunc(self, func):
262        """
263        Set a filtering function.
264        """
265        filter_proxy = self.view().model()
266        filter_proxy.setFilterFunc(func)
267
268
269class SortFilterProxyModel(QSortFilterProxyModel):
270    """
271    An filter proxy model used to filter items based on a filtering
272    function.
273
274    """
275    def __init__(self, parent=None):
276        QSortFilterProxyModel.__init__(self, parent)
277
278        self.__filterFunc = None
279
280    def setFilterFunc(self, func):
281        """
282        Set the filtering function.
283        """
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
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):
316    """
317    Stack widget for the menu pages.
318    """
319
320    def sizeHint(self):
321        """
322        Size hint is the maximum width and median height of the widgets
323        contained in the stack.
324
325        """
326        default_size = QSize(200, 400)
327        widget_hints = [default_size]
328        for i in range(self.count()):
329            hint = self.widget(i).sizeHint()
330            widget_hints.append(hint)
331
332        width = max([s.width() for s in widget_hints])
333        # Take the median for the height
334        height = numpy.median([s.height() for s in widget_hints])
335
336        return QSize(width, int(height))
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
348        else:
349            height = hint.height()
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):
374        opt = QStyleOptionToolButton()
375        self.initStyleOption(opt)
376        opt.features |= QStyleOptionToolButton.HasMenu
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:
384            p = QStylePainter(self)
385            p.drawComplexControl(QStyle.CC_ToolButton, opt)
386
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
396
397_Tab = \
398    namedtuple(
399        "_Tab",
400        ["text",
401         "icon",
402         "toolTip",
403         "button",
404         "data",
405         "palette"])
406
407
408class TabBarWidget(QWidget):
409    """
410    A tab bar widget using tool buttons as tabs.
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)
418        layout = QVBoxLayout()
419        layout.setContentsMargins(0, 0, 0, 0)
420        layout.setSpacing(0)
421        self.setLayout(layout)
422
423        self.setSizePolicy(QSizePolicy.Fixed,
424                           QSizePolicy.Expanding)
425        self.__tabs = []
426
427        self.__currentIndex = -1
428        self.__changeOnHover = False
429
430        self.__iconSize = QSize(26, 26)
431
432        self.__group = QButtonGroup(self, exclusive=True)
433        self.__group.buttonPressed[QAbstractButton].connect(
434            self.__onButtonPressed
435        )
436
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
463    def count(self):
464        """
465        Return the number of tabs in the widget.
466        """
467        return len(self.__tabs)
468
469    def addTab(self, text, icon=None, toolTip=None):
470        """
471        Add a new tab and return it's index.
472        """
473        return self.insertTab(self.count(), text, icon, toolTip)
474
475    def insertTab(self, index, text, icon=None, toolTip=None):
476        """
477        Insert a tab at `index`
478        """
479        button = TabButton(self, objectName="tab-button")
480        button.setSizePolicy(QSizePolicy.Expanding,
481                             QSizePolicy.Expanding)
482        button.setIconSize(self.__iconSize)
483
484        self.__group.addButton(button)
485
486        button.installEventFilter(self.__hoverListener)
487
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):
499        """
500        Remove a tab at `index`.
501        """
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)
506
507            tab.button.removeEventFilter(self.__hoverListener)
508
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):
518        """
519        Set the `icon` for tab at `index`.
520        """
521        self.__tabs[index] = self.__tabs[index]._replace(icon=icon)
522        self.__updateTab(index)
523
524    def setTabToolTip(self, index, toolTip):
525        """
526        Set `toolTip` for tab at `index`.
527        """
528        self.__tabs[index] = self.__tabs[index]._replace(toolTip=toolTip)
529        self.__updateTab(index)
530
531    def setTabText(self, index, text):
532        """
533        Set tab `text` for tab at `index`
534        """
535        self.__tabs[index] = self.__tabs[index]._replace(text=text)
536        self.__updateTab(index)
537
538    def setTabPalette(self, index, palette):
539        """
540        Set the tab button palette.
541        """
542        self.__tabs[index] = self.__tabs[index]._replace(palette=palette)
543        self.__updateTab(index)
544
545    def setCurrentIndex(self, index):
546        """
547        Set the current tab index.
548        """
549        if self.__currentIndex != index:
550            self.__currentIndex = index
551
552            if index != -1:
553                self.__tabs[index].button.setChecked(True)
554
555            self.currentChanged.emit(index)
556
557    def currentIndex(self):
558        """
559        Return the current index.
560        """
561        return self.__currentIndex
562
563    def button(self, index):
564        """
565        Return the `TabButton` instance for index.
566        """
567        return self.__tabs[index].button
568
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
575    def __updateTab(self, index):
576        """
577        Update the tab button.
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
600    def __onButtonEnter(self, button):
601        if self.__changeOnHover:
602            button.click()
603
604
605class PagedMenu(QWidget):
606    """
607    Tabbed container for :class:`MenuPage` instances.
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
620        layout = QHBoxLayout()
621        layout.setContentsMargins(0, 0, 0, 0)
622        layout.setSpacing(0)
623
624        self.__tab = TabBarWidget(self)
625        self.__tab.currentChanged.connect(self.setCurrentIndex)
626        self.__tab.setChangeOnHover(True)
627
628        self.__stack = MenuStackWidget(self)
629
630        layout.addWidget(self.__tab, alignment=Qt.AlignTop)
631        layout.addWidget(self.__stack)
632
633        self.setLayout(layout)
634
635    def addPage(self, page, title, icon=None, toolTip=None):
636        """
637        Add a `page` to the menu and return its index.
638        """
639        return self.insertPage(self.count(), page, title, icon, toolTip)
640
641    def insertPage(self, index, page, title, icon=None, toolTip=None):
642        """
643        Insert `page` at `index`.
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)
650        return index
651
652    def page(self, index):
653        """
654        Return the page at index.
655        """
656        return self.__stack.widget(index)
657
658    def removePage(self, index):
659        """
660        Remove the page at `index`.
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):
670        """
671        Return the number of pages.
672        """
673        return self.__stack.count()
674
675    def setCurrentIndex(self, index):
676        """
677        Set the current page index.
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):
686        """
687        Return the index of the current page.
688        """
689        return self.__currentIndex
690
691    def setCurrentPage(self, page):
692        """
693        Set `page` to be the current shown page.
694        """
695        index = self.__stack.indexOf(page)
696        self.setCurrentIndex(index)
697
698    def currentPage(self):
699        """
700        Return the current page.
701        """
702        return self.__stack.currentWidget()
703
704    def indexOf(self, page):
705        """
706        Return the index of `page`.
707        """
708        return self.__stack.indexOf(page)
709
710    def tabButton(self, index):
711        """
712        Return the tab button instance for index.
713        """
714        return self.__tab.button(index)
715
716
717class QuickMenu(FramelessWindow):
718    """
719    A quick menu popup for the widgets.
720
721    The widgets are set using :func:`QuickMenu.setModel` which must be a
722    model as returned by :func:`QtWidgetRegistry.model`
723
724    """
725
726    #: An action has been triggered in the menu.
727    triggered = Signal(QAction)
728
729    #: An action has been hovered in the menu
730    hovered = Signal(QAction)
731
732    def __init__(self, parent=None, **kwargs):
733        FramelessWindow.__init__(self, parent, **kwargs)
734        self.setWindowFlags(Qt.Popup)
735
736        self.__filterFunc = None
737
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
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
756        self.__frame = QFrame(self, objectName="menu-frame")
757        layout = QVBoxLayout()
758        layout.setContentsMargins(0, 0, 0, 0)
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
778        if sys.platform == "darwin":
779            view = self.__suggestPage.view()
780            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
781            # Don't show the focus frame because it expands into the tab bar.
782            view.setAttribute(Qt.WA_MacShowFocusRect, False)
783
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")
792
793        self.__search.textEdited.connect(self.__on_textEdited)
794
795        self.__navigator = ItemViewKeyNavigator(self)
796        self.__navigator.setView(self.__suggestPage.view())
797        self.__search.installEventFilter(self.__navigator)
798
799        self.__grip = WindowSizeGrip(self)
800        self.__grip.raise_()
801
802    def setSizeGripEnabled(self, enabled):
803        """
804        Enable the resizing of the menu with a size grip in a bottom
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):
817        """
818        Is the size grip enabled.
819        """
820        return bool(self.__grip)
821
822    def addPage(self, name, page):
823        """
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
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)
839
840        # Install event filter to intercept key presses.
841        page.view().installEventFilter(self)
842
843        return index
844
845    def createPage(self, index):
846        """
847        Create a new page based on the contents of an index
848        (:class:`QModeIndex`) item.
849
850        """
851        page = MenuPage(self)
852
853        page.setModel(index.model())
854        page.setRootIndex(index)
855
856        view = page.view()
857
858        if sys.platform == "darwin":
859            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
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)
863
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):
875        """
876        Set the model containing the actions.
877        """
878        root = model.invisibleRootItem()
879        for i in range(root.rowCount()):
880            item = root.child(i)
881            index = item.index()
882            page = self.createPage(index)
883            page.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE)
884            i = self.addPage(page.title(), page)
885
886            brush = index.data(QtWidgetRegistry.BACKGROUND_ROLE)
887
888            if brush.isValid():
889                brush = brush.toPyObject()
890                base_color = brush.color()
891                button = self.__pages.tabButton(i)
892                button.setStyleSheet(
893                    "TabButton {\n"
894                    "    qproperty-flat_: false;\n"
895                    "    background: %s;\n"
896                    "    border: none;\n"
897                    "    border-bottom: 1px solid palette(dark);\n"
898                    "}\n"
899                    "TabButton:checked {\n"
900                    "    background: %s\n"
901                    "}" % (create_css_gradient(base_color),
902                           create_css_gradient(base_color.darker(110)))
903                )
904
905        self.__model = model
906        self.__suggestPage.setModel(model)
907
908    def setFilterFunc(self, func):
909        """
910        Set a filter function.
911        """
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)
916
917    def popup(self, pos=None, searchText=""):
918        """
919        Popup the menu at `pos` (in screen coordinates). 'Search' text field
920        is initialized with `searchText` if provided.
921        """
922        if pos is None:
923            pos = QPoint()
924
925        self.__search.setText(searchText)
926        self.__suggestPage.setFilterFixedString(searchText)
927
928        self.ensurePolished()
929
930        if self.testAttribute(Qt.WA_Resized) and self.sizeGripEnabled():
931            size = self.size()
932        else:
933            size = self.sizeHint()
934
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
968        if searchText:
969            self.setFocusProxy(self.__search)
970        else:
971            self.setFocusProxy(None)
972
973    def exec_(self, pos=None, searchText=""):
974        """
975        Execute the menu at position `pos` (in global screen coordinates).
976        Return the triggered :class:`QAction` or `None` if no action was
977        triggered. 'Search' text field is initialized with `searchText` if
978        provided.
979
980        """
981        self.popup(pos, searchText)
982        self.setFocus(Qt.PopupFocusReason)
983
984        self.__triggeredAction = None
985        self.__loop = QEventLoop()
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):
995        """
996        Reimplemented from :class:`QWidget`
997        """
998        FramelessWindow.hideEvent(self, event)
999        if self.__loop:
1000            self.__loop.exit()
1001
1002    def setCurrentPage(self, page):
1003        """
1004        Set the current shown page to `page`.
1005        """
1006        self.__pages.setCurrentPage(page)
1007
1008    def setCurrentIndex(self, index):
1009        """
1010        Set the current page index.
1011        """
1012        self.__pages.setCurrentIndex(index)
1013
1014    def __onTriggered(self, action):
1015        """
1016        Re-emit the action from the page.
1017        """
1018        self.__triggeredAction = action
1019
1020        # Hide and exit the event loop if necessary.
1021        self.hide()
1022        self.triggered.emit(action)
1023
1024    def __on_textEdited(self, text):
1025        self.__suggestPage.setFilterFixedString(text)
1026        self.__pages.setCurrentPage(self.__suggestPage)
1027
1028    def triggerSearch(self):
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        """
1034        self.__pages.setCurrentPage(self.__suggestPage)
1035        self.__search.setFocus(Qt.ShortcutFocusReason)
1036
1037        # Make sure that the first enabled item is set current.
1038        self.__suggestPage.ensureCurrent()
1039
1040    def keyPressEvent(self, event):
1041        if event.text():
1042            # Ignore modifiers, ...
1043            self.__search.setFocus(Qt.ShortcutFocusReason)
1044            self.setCurrentIndex(0)
1045            self.__search.keyPressEvent(event)
1046
1047        FramelessWindow.keyPressEvent(self, event)
1048        event.accept()
1049
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
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
1072class ItemViewKeyNavigator(QObject):
1073    """
1074    A event filter class listening to key press events and responding
1075    by moving 'currentItem` on a :class:`QListView`.
1076
1077    """
1078    def __init__(self, parent=None):
1079        QObject.__init__(self, parent)
1080        self.__view = None
1081
1082    def setView(self, view):
1083        """
1084        Set the QListView.
1085        """
1086        if self.__view != view:
1087            self.__view = view
1088
1089    def view(self):
1090        """
1091        Return the view
1092        """
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):
1115        """
1116        Move the current index by rows, columns.
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):
1137        """
1138        Activate the current index.
1139        """
1140        if self.__view is not None:
1141            curr = self.__view.currentIndex()
1142            if curr.isValid():
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.
1147                self.__view.activated.emit(curr)
1148
1149    def ensureCurrent(self):
1150        """
1151        Ensure the view has a current item if one is available.
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
1162
1163
1164class WindowSizeGrip(QSizeGrip):
1165    """
1166    Automatically positioning :class:`QSizeGrip`.
1167    The widget automatically maintains its position in the window
1168    corner during resize events.
1169
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):
1180        """
1181        Set the corner (:class:`Qt.Corner`) where the size grip should
1182        position itself.
1183
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):
1194        """
1195        Return the corner where the size grip is positioned.
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)
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.