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

Revision 11491:d8183bc8cf38, 35.4 KB checked in by Ales Erjavec <ales.erjavec@…>, 11 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.

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
154class ItemDisableFilter(QSortFilterProxyModel):
155    """
156    An filter proxy model used to disable selected items based on
157    a filtering function.
158
159    """
160    def __init__(self, parent=None):
161        QSortFilterProxyModel.__init__(self, parent)
162
163        self.__filterFunc = None
164
165    def setFilterFunc(self, func):
166        """
167        Set the filtering function.
168        """
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):
179        """
180        Reimplemented from :class:`QSortFilterProxyModel.flags`
181        """
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):
194    """
195    A MenuMage for the QuickMenu widget supporting item filtering
196    (searching).
197
198    """
199    def __init__(self, *args, **kwargs):
200        MenuPage.__init__(self, *args, **kwargs)
201
202    def setModel(self, model):
203        """
204        Reimplmemented from :ref:`MenuPage.setModel`.
205        """
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):
217        """
218        Set the fixed string filtering pattern. Only items which contain the
219        `pattern` string will be shown.
220
221        """
222        proxy = self.view().model()
223        proxy.setFilterFixedString(pattern)
224        self.ensureCurrent()
225
226    def setFilterRegExp(self, pattern):
227        """
228        Set the regular expression filtering pattern. Only items matching
229        the `pattern` expression will be shown.
230
231        """
232        filter_proxy = self.view().model()
233        filter_proxy.setFilterRegExp(pattern)
234        self.ensureCurrent()
235
236    def setFilterWildCard(self, pattern):
237        """
238        Set a wildcard filtering pattern.
239        """
240        filter_proxy = self.view().model()
241        filter_proxy.setFilterWildCard(pattern)
242        self.ensureCurrent()
243
244    def setFilterFunc(self, func):
245        """
246        Set a filtering function.
247        """
248        filter_proxy = self.view().model()
249        filter_proxy.setFilterFunc(func)
250
251
252class SortFilterProxyModel(QSortFilterProxyModel):
253    """
254    An filter proxy model used to filter items based on a filtering
255    function.
256
257    """
258    def __init__(self, parent=None):
259        QSortFilterProxyModel.__init__(self, parent)
260
261        self.__filterFunc = None
262
263    def setFilterFunc(self, func):
264        """
265        Set the filtering function.
266        """
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
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):
299    """
300    Stack widget for the menu pages.
301    """
302
303    def sizeHint(self):
304        """
305        Size hint is the maximum width and median height of the widgets
306        contained in the stack.
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
320        height = numpy.median([s.height() for s in widget_hints])
321
322        return QSize(width, int(height))
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
334        else:
335            height = hint.height()
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):
360        opt = QStyleOptionToolButton()
361        self.initStyleOption(opt)
362        opt.features |= QStyleOptionToolButton.HasMenu
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:
370            p = QStylePainter(self)
371            p.drawComplexControl(QStyle.CC_ToolButton, opt)
372
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
382
383_Tab = \
384    namedtuple(
385        "_Tab",
386        ["text",
387         "icon",
388         "toolTip",
389         "button",
390         "data",
391         "palette"])
392
393
394class TabBarWidget(QWidget):
395    """
396    A tab bar widget using tool buttons as tabs.
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)
404        layout = QVBoxLayout()
405        layout.setContentsMargins(0, 0, 0, 0)
406        layout.setSpacing(0)
407        self.setLayout(layout)
408
409        self.setSizePolicy(QSizePolicy.Fixed,
410                           QSizePolicy.Expanding)
411        self.__tabs = []
412
413        self.__currentIndex = -1
414        self.__changeOnHover = False
415
416        self.__iconSize = QSize(26, 26)
417
418        self.__group = QButtonGroup(self, exclusive=True)
419        self.__group.buttonPressed[QAbstractButton].connect(
420            self.__onButtonPressed
421        )
422
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
449    def count(self):
450        """
451        Return the number of tabs in the widget.
452        """
453        return len(self.__tabs)
454
455    def addTab(self, text, icon=None, toolTip=None):
456        """
457        Add a new tab and return it's index.
458        """
459        return self.insertTab(self.count(), text, icon, toolTip)
460
461    def insertTab(self, index, text, icon=None, toolTip=None):
462        """
463        Insert a tab at `index`
464        """
465        button = TabButton(self, objectName="tab-button")
466        button.setSizePolicy(QSizePolicy.Expanding,
467                             QSizePolicy.Expanding)
468        button.setIconSize(self.__iconSize)
469
470        self.__group.addButton(button)
471
472        button.installEventFilter(self.__hoverListener)
473
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):
485        """
486        Remove a tab at `index`.
487        """
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)
492
493            tab.button.removeEventFilter(self.__hoverListener)
494
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):
504        """
505        Set the `icon` for tab at `index`.
506        """
507        self.__tabs[index] = self.__tabs[index]._replace(icon=icon)
508        self.__updateTab(index)
509
510    def setTabToolTip(self, index, toolTip):
511        """
512        Set `toolTip` for tab at `index`.
513        """
514        self.__tabs[index] = self.__tabs[index]._replace(toolTip=toolTip)
515        self.__updateTab(index)
516
517    def setTabText(self, index, text):
518        """
519        Set tab `text` for tab at `index`
520        """
521        self.__tabs[index] = self.__tabs[index]._replace(text=text)
522        self.__updateTab(index)
523
524    def setTabPalette(self, index, palette):
525        """
526        Set the tab button palette.
527        """
528        self.__tabs[index] = self.__tabs[index]._replace(palette=palette)
529        self.__updateTab(index)
530
531    def setCurrentIndex(self, index):
532        """
533        Set the current tab index.
534        """
535        if self.__currentIndex != index:
536            self.__currentIndex = index
537
538            if index != -1:
539                self.__tabs[index].button.setChecked(True)
540
541            self.currentChanged.emit(index)
542
543    def currentIndex(self):
544        """
545        Return the current index.
546        """
547        return self.__currentIndex
548
549    def button(self, index):
550        """
551        Return the `TabButton` instance for index.
552        """
553        return self.__tabs[index].button
554
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
561    def __updateTab(self, index):
562        """
563        Update the tab button.
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
586    def __onButtonEnter(self, button):
587        if self.__changeOnHover:
588            button.click()
589
590
591class PagedMenu(QWidget):
592    """
593    Tabbed container for :class:`MenuPage` instances.
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
606        layout = QHBoxLayout()
607        layout.setContentsMargins(0, 0, 0, 0)
608        layout.setSpacing(0)
609
610        self.__tab = TabBarWidget(self)
611        self.__tab.currentChanged.connect(self.setCurrentIndex)
612        self.__tab.setChangeOnHover(True)
613
614        self.__stack = MenuStackWidget(self)
615
616        layout.addWidget(self.__tab, alignment=Qt.AlignTop)
617        layout.addWidget(self.__stack)
618
619        self.setLayout(layout)
620
621    def addPage(self, page, title, icon=None, toolTip=None):
622        """
623        Add a `page` to the menu and return its index.
624        """
625        return self.insertPage(self.count(), page, title, icon, toolTip)
626
627    def insertPage(self, index, page, title, icon=None, toolTip=None):
628        """
629        Insert `page` at `index`.
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)
636        return index
637
638    def page(self, index):
639        """
640        Return the page at index.
641        """
642        return self.__stack.widget(index)
643
644    def removePage(self, index):
645        """
646        Remove the page at `index`.
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):
656        """
657        Return the number of pages.
658        """
659        return self.__stack.count()
660
661    def setCurrentIndex(self, index):
662        """
663        Set the current page index.
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):
672        """
673        Return the index of the current page.
674        """
675        return self.__currentIndex
676
677    def setCurrentPage(self, page):
678        """
679        Set `page` to be the current shown page.
680        """
681        index = self.__stack.indexOf(page)
682        self.setCurrentIndex(index)
683
684    def currentPage(self):
685        """
686        Return the current page.
687        """
688        return self.__stack.currentWidget()
689
690    def indexOf(self, page):
691        """
692        Return the index of `page`.
693        """
694        return self.__stack.indexOf(page)
695
696    def tabButton(self, index):
697        """
698        Return the tab button instance for index.
699        """
700        return self.__tab.button(index)
701
702
703class QuickMenu(FramelessWindow):
704    """
705    A quick menu popup for the widgets.
706
707    The widgets are set using :func:`QuickMenu.setModel` which must be a
708    model as returned by :func:`QtWidgetRegistry.model`
709
710    """
711
712    #: An action has been triggered in the menu.
713    triggered = Signal(QAction)
714
715    #: An action has been hovered in the menu
716    hovered = Signal(QAction)
717
718    def __init__(self, parent=None, **kwargs):
719        FramelessWindow.__init__(self, parent, **kwargs)
720        self.setWindowFlags(Qt.Popup)
721
722        self.__filterFunc = None
723
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
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
742        self.__frame = QFrame(self, objectName="menu-frame")
743        layout = QVBoxLayout()
744        layout.setContentsMargins(0, 0, 0, 0)
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
764        if sys.platform == "darwin":
765            view = self.__suggestPage.view()
766            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
767            # Don't show the focus frame because it expands into the tab bar.
768            view.setAttribute(Qt.WA_MacShowFocusRect, False)
769
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")
778
779        self.__search.textEdited.connect(self.__on_textEdited)
780
781        self.__navigator = ItemViewKeyNavigator(self)
782        self.__navigator.setView(self.__suggestPage.view())
783        self.__search.installEventFilter(self.__navigator)
784
785        self.__grip = WindowSizeGrip(self)
786        self.__grip.raise_()
787
788    def setSizeGripEnabled(self, enabled):
789        """
790        Enable the resizing of the menu with a size grip in a bottom
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):
803        """
804        Is the size grip enabled.
805        """
806        return bool(self.__grip)
807
808    def addPage(self, name, page):
809        """
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
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)
825
826        # Install event filter to intercept key presses.
827        page.view().installEventFilter(self)
828
829        return index
830
831    def createPage(self, index):
832        """
833        Create a new page based on the contents of an index
834        (:class:`QModeIndex`) item.
835
836        """
837        page = MenuPage(self)
838
839        page.setModel(index.model())
840        page.setRootIndex(index)
841
842        view = page.view()
843
844        if sys.platform == "darwin":
845            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
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)
849
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):
861        """
862        Set the model containing the actions.
863        """
864        root = model.invisibleRootItem()
865        for i in range(root.rowCount()):
866            item = root.child(i)
867            index = item.index()
868            page = self.createPage(index)
869            page.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE)
870            i = self.addPage(page.title(), page)
871
872            brush = index.data(QtWidgetRegistry.BACKGROUND_ROLE)
873
874            if brush.isValid():
875                brush = brush.toPyObject()
876                base_color = brush.color()
877                button = self.__pages.tabButton(i)
878                button.setStyleSheet(
879                    "TabButton {\n"
880                    "    qproperty-flat_: false;\n"
881                    "    background: %s;\n"
882                    "    border: none;\n"
883                    "    border-bottom: 1px solid palette(dark);\n"
884                    "}\n"
885                    "TabButton:checked {\n"
886                    "    background: %s\n"
887                    "}" % (create_css_gradient(base_color),
888                           create_css_gradient(base_color.darker(110)))
889                )
890
891        self.__model = model
892        self.__suggestPage.setModel(model)
893
894    def setFilterFunc(self, func):
895        """
896        Set a filter function.
897        """
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)
902
903    def popup(self, pos=None, searchText=""):
904        """
905        Popup the menu at `pos` (in screen coordinates). 'Search' text field
906        is initialized with `searchText` if provided.
907        """
908        if pos is None:
909            pos = QPoint()
910
911        self.__search.setText(searchText)
912        self.__suggestPage.setFilterFixedString(searchText)
913
914        self.ensurePolished()
915
916        if self.testAttribute(Qt.WA_Resized) and self.sizeGripEnabled():
917            size = self.size()
918        else:
919            size = self.sizeHint()
920
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
954        if searchText:
955            self.setFocusProxy(self.__search)
956        else:
957            self.setFocusProxy(None)
958
959    def exec_(self, pos=None, searchText=""):
960        """
961        Execute the menu at position `pos` (in global screen coordinates).
962        Return the triggered :class:`QAction` or `None` if no action was
963        triggered. 'Search' text field is initialized with `searchText` if
964        provided.
965
966        """
967        self.popup(pos, searchText)
968        self.setFocus(Qt.PopupFocusReason)
969
970        self.__triggeredAction = None
971        self.__loop = QEventLoop()
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):
981        """
982        Reimplemented from :class:`QWidget`
983        """
984        FramelessWindow.hideEvent(self, event)
985        if self.__loop:
986            self.__loop.exit()
987
988    def setCurrentPage(self, page):
989        """
990        Set the current shown page to `page`.
991        """
992        self.__pages.setCurrentPage(page)
993
994    def setCurrentIndex(self, index):
995        """
996        Set the current page index.
997        """
998        self.__pages.setCurrentIndex(index)
999
1000    def __onTriggered(self, action):
1001        """
1002        Re-emit the action from the page.
1003        """
1004        self.__triggeredAction = action
1005
1006        # Hide and exit the event loop if necessary.
1007        self.hide()
1008        self.triggered.emit(action)
1009
1010    def __on_textEdited(self, text):
1011        self.__suggestPage.setFilterFixedString(text)
1012        self.__pages.setCurrentPage(self.__suggestPage)
1013
1014    def triggerSearch(self):
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        """
1020        self.__pages.setCurrentPage(self.__suggestPage)
1021        self.__search.setFocus(Qt.ShortcutFocusReason)
1022
1023        # Make sure that the first enabled item is set current.
1024        self.__suggestPage.ensureCurrent()
1025
1026    def keyPressEvent(self, event):
1027        if event.text():
1028            # Ignore modifiers, ...
1029            self.__search.setFocus(Qt.ShortcutFocusReason)
1030            self.setCurrentIndex(0)
1031            self.__search.keyPressEvent(event)
1032
1033        FramelessWindow.keyPressEvent(self, event)
1034        event.accept()
1035
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
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
1058class ItemViewKeyNavigator(QObject):
1059    """
1060    A event filter class listening to key press events and responding
1061    by moving 'currentItem` on a :class:`QListView`.
1062
1063    """
1064    def __init__(self, parent=None):
1065        QObject.__init__(self, parent)
1066        self.__view = None
1067
1068    def setView(self, view):
1069        """
1070        Set the QListView.
1071        """
1072        if self.__view != view:
1073            self.__view = view
1074
1075    def view(self):
1076        """
1077        Return the view
1078        """
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):
1101        """
1102        Move the current index by rows, columns.
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):
1123        """
1124        Activate the current index.
1125        """
1126        if self.__view is not None:
1127            curr = self.__view.currentIndex()
1128            if curr.isValid():
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.
1133                self.__view.activated.emit(curr)
1134
1135    def ensureCurrent(self):
1136        """
1137        Ensure the view has a current item if one is available.
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
1148
1149
1150class WindowSizeGrip(QSizeGrip):
1151    """
1152    Automatically positioning :class:`QSizeGrip`.
1153    The widget automatically maintains its position in the window
1154    corner during resize events.
1155
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):
1166        """
1167        Set the corner (:class:`Qt.Corner`) where the size grip should
1168        position itself.
1169
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):
1180        """
1181        Return the corner where the size grip is positioned.
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)
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.