source: orange/Orange/OrangeCanvas/canvas/quickmenu.py @ 11133:cde8738d4bbb

Revision 11133:cde8738d4bbb, 20.4 KB checked in by Ales Erjavec <ales.erjavec@…>, 18 months ago (diff)

Store widget's background brush in a custom user role.

Line 
1"""
2Quick widget selector menu for the canvas.
3
4"""
5import sys
6import logging
7
8from collections import namedtuple
9
10from PyQt4.QtGui import (
11    QWidget, QFrame, QToolButton, QAbstractButton, QAction, QIcon,
12    QButtonGroup, QStackedWidget, QHBoxLayout, QVBoxLayout, QSizePolicy,
13    QStandardItemModel, QSortFilterProxyModel, QStyleOptionToolButton,
14    QStylePainter, QStyle, QApplication
15)
16
17from PyQt4.QtCore import pyqtSignal as Signal
18from PyQt4.QtCore import pyqtProperty as Property
19
20from PyQt4.QtCore import (
21    Qt, QObject, QPoint, QSize, QRect, QEventLoop, QEvent
22)
23
24
25from ..gui.framelesswindow import FramelessWindow
26from ..gui.lineedit import LineEdit
27from ..gui.tooltree import ToolTree, FlattenedTreeItemModel
28from ..gui.utils import StyledWidget_paintEvent
29
30from ..registry.qt import QtWidgetRegistry
31
32from ..resources import icon_loader
33
34log = logging.getLogger(__name__)
35
36
37class SearchWidget(LineEdit):
38    def __init__(self, parent=None, **kwargs):
39        LineEdit.__init__(self, parent, **kwargs)
40        self.__setupUi()
41
42    def __setupUi(self):
43        icon = icon_loader().get("icons/Search.svg")
44        action = QAction(icon, "Search", self)
45
46        self.setAction(action, LineEdit.LeftPosition)
47
48
49class MenuStackWidget(QStackedWidget):
50    """Stack widget for the menu pages (ToolTree instances).
51    """
52
53    def sizeHint(self):
54        """Size hint is the median size hint of the widgets contained
55        within.
56
57        """
58        default_size = QSize(200, 400)
59        widget_hints = [default_size]
60        for i in range(self.count()):
61            w = self.widget(i)
62            if isinstance(w, ToolTree):
63                hint = self.__sizeHintForTreeView(w.view())
64            else:
65                hint = w.sizeHint()
66            widget_hints.append(hint)
67        width = max([s.width() for s in widget_hints])
68        # Take the median for the height
69        heights = sorted([s.height() for s in widget_hints])
70        height = heights[len(heights) / 2]
71        return QSize(width, height)
72
73    def __sizeHintForTreeView(self, view):
74        hint = view.sizeHint()
75        model = view.model()
76
77        count = model.rowCount()
78        width = view.sizeHintForColumn(0)
79
80        if count:
81            height = view.sizeHintForRow(0)
82            height = height * count
83        else:
84            height = hint.height()
85
86        return QSize(max(width, hint.width()), max(height, hint.height()))
87
88
89class TabButton(QToolButton):
90    def __init__(self, parent=None, **kwargs):
91        QToolButton.__init__(self, parent, **kwargs)
92        self.setToolButtonStyle(Qt.ToolButtonIconOnly)
93        self.setCheckable(True)
94
95        self.__flat = True
96
97    def setFlat(self, flat):
98        if self.__flat != flat:
99            self.__flat = flat
100            self.update()
101
102    def flat(self):
103        return self.__flat
104
105    flat_ = Property(bool, fget=flat, fset=setFlat,
106                     designable=True)
107
108    def paintEvent(self, event):
109        if self.__flat:
110            # Use default widget background/border styling.
111            StyledWidget_paintEvent(self, event)
112
113            opt = QStyleOptionToolButton()
114            self.initStyleOption(opt)
115            p = QStylePainter(self)
116            p.drawControl(QStyle.CE_ToolButtonLabel, opt)
117        else:
118            QToolButton.paintEvent(self, event)
119
120
121_Tab = \
122    namedtuple(
123        "_Tab",
124        ["text",
125         "icon",
126         "toolTip",
127         "button",
128         "data",
129         "palette"])
130
131
132class TabBarWidget(QWidget):
133    """A tab bar widget using tool buttons as tabs.
134
135    """
136    # TODO: A uniform size box layout.
137
138    currentChanged = Signal(int)
139
140    def __init__(self, parent=None, **kwargs):
141        QWidget.__init__(self, parent, **kwargs)
142        layout = QHBoxLayout()
143        layout.setContentsMargins(0, 0, 0, 0)
144        layout.setSpacing(0)
145        self.setLayout(layout)
146
147        self.setSizePolicy(QSizePolicy.Expanding,
148                           QSizePolicy.Fixed)
149        self.__tabs = []
150        self.__currentIndex = -1
151        self.__group = QButtonGroup(self, exclusive=True)
152        self.__group.buttonPressed[QAbstractButton].connect(
153            self.__onButtonPressed
154        )
155
156    def count(self):
157        """Return the number of tabs in the widget.
158        """
159        return len(self.__tabs)
160
161    def addTab(self, text, icon=None, toolTip=None):
162        """Add a tab and return it's index.
163        """
164        return self.insertTab(self.count(), text, icon, toolTip)
165
166    def insertTab(self, index, text, icon, toolTip):
167        """Insert a tab at `index`
168        """
169        button = TabButton(self, objectName="tab-button")
170        button.setSizePolicy(QSizePolicy.Expanding,
171                             QSizePolicy.Expanding)
172
173        self.__group.addButton(button)
174        tab = _Tab(text, icon, toolTip, button, None, None)
175        self.layout().insertWidget(index, button)
176
177        self.__tabs.insert(index, tab)
178        self.__updateTab(index)
179
180        if self.currentIndex() == -1:
181            self.setCurrentIndex(0)
182        return index
183
184    def removeTab(self, index):
185        if index >= 0 and index < self.count():
186            self.layout().takeItem(index)
187            tab = self.__tabs.pop(index)
188            self.__group.removeButton(tab.button)
189            tab.button.deleteLater()
190
191            if self.currentIndex() == index:
192                if self.count():
193                    self.setCurrentIndex(max(index - 1, 0))
194                else:
195                    self.setCurrentIndex(-1)
196
197    def setTabIcon(self, index, icon):
198        """Set the `icon` for tab at `index`.
199        """
200        self.__tabs[index] = self.__tabs[index]._replace(icon=icon)
201        self.__updateTab(index)
202
203    def setTabToolTip(self, index, toolTip):
204        """Set `toolTip` for tab at `index`.
205        """
206        self.__tabs[index] = self.__tabs[index]._replace(toolTip=toolTip)
207        self.__updateTab(index)
208
209    def setTabText(self, index, text):
210        """Set tab `text` for tab at `index`
211        """
212        self.__tabs[index] = self.__tabs[index]._replace(text=text)
213        self.__updateTab(index)
214
215    def setTabPalette(self, index, palette):
216        """Set the tab button palette.
217        """
218        self.__tabs[index] = self.__tabs[index]._replace(palette=palette)
219        self.__updateTab(index)
220
221    def setCurrentIndex(self, index):
222        if self.__currentIndex != index:
223            self.__currentIndex = index
224
225            if index != -1:
226                self.__tabs[index].button.setChecked(True)
227
228            self.currentChanged.emit(index)
229
230    def button(self, index):
231        """Return the `TabButton` instance for index.
232        """
233        return self.__tabs[index].button
234
235    def currentIndex(self):
236        """Return the current index.
237        """
238        return self.__currentIndex
239
240    def __updateTab(self, index):
241        """Update the tab button.
242        """
243        tab = self.__tabs[index]
244        b = tab.button
245
246        if tab.text:
247            b.setText(tab.text)
248
249        if tab.icon is not None and not tab.icon.isNull():
250            b.setIcon(tab.icon)
251
252        if tab.toolTip:
253            b.setToolTip(tab.toolTip)
254
255        if tab.palette:
256            b.setPalette(tab.palette)
257
258    def __onButtonPressed(self, button):
259        for i, tab in enumerate(self.__tabs):
260            if tab.button is button:
261                self.setCurrentIndex(i)
262                break
263
264
265class PagedMenu(QWidget):
266    """Tabed container for `ToolTree` instances.
267    """
268    triggered = Signal(QAction)
269    hovered = Signal(QAction)
270
271    currentChanged = Signal(int)
272
273    def __init__(self, parent=None, **kwargs):
274        QWidget.__init__(self, parent, **kwargs)
275
276        self.__pages = []
277        self.__currentIndex = -1
278
279        layout = QVBoxLayout()
280        layout.setContentsMargins(0, 0, 0, 0)
281        layout.setSpacing(0)
282
283        self.__tab = TabBarWidget(self)
284        self.__tab.setFixedHeight(25)
285        self.__tab.currentChanged.connect(self.setCurrentIndex)
286
287        self.__stack = MenuStackWidget(self)
288
289        layout.addWidget(self.__tab)
290        layout.addWidget(self.__stack)
291
292        self.setLayout(layout)
293
294    def addPage(self, page, title, icon=None, toolTip=None):
295        """Add a `page` to the menu and return its index.
296        """
297        return self.insertPage(self.count(), page, title, icon, toolTip)
298
299    def insertPage(self, index, page, title, icon=None, toolTip=None):
300        """Insert `page` at `index`.
301        """
302        page.triggered.connect(self.triggered)
303        page.hovered.connect(self.hovered)
304
305        self.__stack.insertWidget(index, page)
306        self.__tab.insertTab(index, title, icon, toolTip)
307        return index
308
309    def page(self, index):
310        """Return the page at index.
311        """
312        return self.__stack.widget(index)
313
314    def removePage(self, index):
315        """Remove the page at `index`.
316        """
317        page = self.__stack.widget(index)
318        page.triggered.disconnect(self.triggered)
319        page.hovered.disconnect(self.hovered)
320
321        self.__stack.removeWidget(page)
322        self.__tab.removeTab(index)
323
324    def count(self):
325        """Return the number of pages.
326        """
327        return self.__stack.count()
328
329    def setCurrentIndex(self, index):
330        """Set the current page index.
331        """
332        if self.__currentIndex != index:
333            self.__currentIndex = index
334            self.__tab.setCurrentIndex(index)
335            self.__stack.setCurrentIndex(index)
336            self.currentChanged.emit(index)
337
338    def currentIndex(self):
339        """Return the index of the current page.
340        """
341        return self.__currentIndex
342
343    def setCurrentPage(self, page):
344        """Set `page` to be the current shown page.
345        """
346        index = self.__stack.indexOf(page)
347        self.setCurrentIndex(index)
348
349    def currentPage(self):
350        """Return the current page.
351        """
352        return self.__stack.currentWidget()
353
354    def indexOf(self, page):
355        """Return the index of `page`.
356        """
357        return self.__stack.indexOf(page)
358
359    def tabButton(self, index):
360        """Return the tab button instance for index.
361        """
362        return self.__tab.button(index)
363
364
365class SuggestMenuPage(ToolTree):
366    def __init__(self, *args, **kwargs):
367        ToolTree.__init__(self, *args, **kwargs)
368
369        # Make sure the initial model is wrapped in a FlattenedTreeItemModel.
370        self.setModel(self.model())
371
372    def setModel(self, model):
373        self.__sourceModel = model
374        flat = FlattenedTreeItemModel(self)
375        flat.setSourceModel(model)
376        flat.setFlatteningMode(flat.InternalNodesDisabled)
377        proxy = QSortFilterProxyModel(self)
378        proxy.setFilterCaseSensitivity(False)
379        proxy.setSourceModel(flat)
380        ToolTree.setModel(self, proxy)
381        self.ensureCurrent()
382
383    def setFilterFixedString(self, pattern):
384        proxy = self.view().model()
385        proxy.setFilterFixedString(pattern)
386        self.ensureCurrent()
387
388    def setFilterRegExp(self, pattern):
389        filter_proxy = self.view().model()
390        filter_proxy.setFilterRegExp(pattern)
391        self.ensureCurrent()
392
393    def setFilterWildCard(self, pattern):
394        filter_proxy = self.view().model()
395        filter_proxy.setFilterWildCard(pattern)
396        self.ensureCurrent()
397
398
399class QuickMenu(FramelessWindow):
400    """A quick menu popup for the widgets.
401
402    The widgets are set using setModel which must be a
403    model as returned by QtWidgetRegistry.model()
404
405    """
406
407    triggered = Signal(QAction)
408    hovered = Signal(QAction)
409
410    def __init__(self, parent=None, **kwargs):
411        FramelessWindow.__init__(self, parent, **kwargs)
412        self.setWindowFlags(Qt.Popup)
413
414        self.__setupUi()
415
416        self.__loop = None
417        self.__model = QStandardItemModel()
418        self.__triggeredAction = None
419
420    def __setupUi(self):
421        self.setLayout(QVBoxLayout(self))
422        self.layout().setContentsMargins(6, 6, 6, 6)
423
424        self.__frame = QFrame(self, objectName="menu-frame")
425        layout = QVBoxLayout()
426        layout.setContentsMargins(1, 1, 1, 1)
427        layout.setSpacing(2)
428        self.__frame.setLayout(layout)
429
430        self.layout().addWidget(self.__frame)
431
432        self.__pages = PagedMenu(self, objectName="paged-menu")
433        self.__pages.currentChanged.connect(self.setCurrentIndex)
434        self.__pages.triggered.connect(self.triggered)
435        self.__pages.hovered.connect(self.hovered)
436
437        self.__frame.layout().addWidget(self.__pages)
438
439        self.__search = SearchWidget(self, objectName="search-line")
440
441        self.__search.setPlaceholderText(
442            self.tr("Search for widget or select from the list.")
443        )
444
445        self.layout().addWidget(self.__search)
446        self.setSizePolicy(QSizePolicy.Fixed,
447                           QSizePolicy.Expanding)
448
449        self.__suggestPage = SuggestMenuPage(self, objectName="suggest-page")
450        self.__suggestPage.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE)
451        self.__suggestPage.setIcon(icon_loader().get("icons/Search.svg"))
452
453        self.addPage(self.tr("Quick Access"), self.__suggestPage)
454
455        self.__search.textEdited.connect(
456            self.__suggestPage.setFilterFixedString
457        )
458
459        self.__navigator = ItemViewKeyNavigator(self)
460        self.__navigator.setView(self.__suggestPage.view())
461        self.__search.installEventFilter(self.__navigator)
462
463    def addPage(self, name, page):
464        """Add the page and return it's index.
465        """
466        icon = page.icon()
467
468        tip = name
469        if page.toolTip():
470            tip = page.toolTip()
471
472        index = self.__pages.addPage(page, name, icon, tip)
473        # TODO: get the background.
474
475        # Route the page's signals
476        page.triggered.connect(self.__onTriggered)
477        page.hovered.connect(self.hovered)
478        return index
479
480    def createPage(self, index):
481        page = ToolTree(self)
482        page.setModel(index.model())
483        page.setRootIndex(index)
484
485        view = page.view()
486
487        if sys.platform == "darwin":
488            view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True)
489
490        name = unicode(index.data(Qt.DisplayRole))
491        page.setTitle(name)
492
493        icon = index.data(Qt.DecorationRole).toPyObject()
494        if isinstance(icon, QIcon):
495            page.setIcon(icon)
496
497        page.setToolTip(index.data(Qt.ToolTipRole).toPyObject())
498
499        return page
500
501    def setModel(self, model):
502        root = model.invisibleRootItem()
503        for i in range(root.rowCount()):
504            item = root.child(i)
505            index = item.index()
506            page = self.createPage(index)
507            page.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE)
508            i = self.addPage(page.title(), page)
509
510            brush = index.data(QtWidgetRegistry.BACKGROUND_ROLE)
511
512            if brush.isValid():
513                brush = brush.toPyObject()
514                button = self.__pages.tabButton(i)
515                palette = button.palette()
516                button.setStyleSheet(
517                    "QToolButton {\n"
518                    "    qproperty-flat_: false;"
519                    "    background-color: %s;\n"
520                    "    border: none;\n"
521                    "}\n"
522                    "QToolButton:checked {\n"
523                    "    border: 1px solid %s;\n"
524                    "}" % (brush.color().name(),
525                           palette.color(palette.Mid).name())
526                )
527
528        self.__model = model
529        self.__suggestPage.setModel(model)
530
531    def popup(self, pos=None):
532        """Popup the menu at `pos` (in screen coordinates)..
533        """
534        if pos is None:
535            pos = QPoint()
536
537        self.__search.setText("")
538        self.__suggestPage.setFilterFixedString("")
539
540        self.ensurePolished()
541        size = self.sizeHint()
542        desktop = QApplication.desktop()
543        screen_geom = desktop.availableGeometry(pos)
544
545        # Adjust the size to fit inside the screen.
546        if size.height() > screen_geom.height():
547            size.setHeight(screen_geom.height())
548        if size.width() > screen_geom.width():
549            size.setWidth(screen_geom.width())
550
551        geom = QRect(pos, size)
552
553        if geom.top() < screen_geom.top():
554            geom.setTop(screen_geom.top())
555
556        if geom.left() < screen_geom.left():
557            geom.setLeft(screen_geom.left())
558
559        bottom_margin = screen_geom.bottom() - geom.bottom()
560        right_margin = screen_geom.right() - geom.right()
561        if bottom_margin < 0:
562            # Falls over the bottom of the screen, move it up.
563            geom.translate(0, bottom_margin)
564
565        # TODO: right to left locale
566        if right_margin < 0:
567            # Falls over the right screen edge, move the menu to the
568            # other side of pos.
569            geom.translate(-size.width(), 0)
570
571        self.setGeometry(geom)
572
573        self.show()
574
575    def exec_(self, pos=None):
576        self.popup(pos)
577        self.setFocus(Qt.PopupFocusReason)
578
579        self.__triggeredAction = None
580        self.__loop = QEventLoop(self)
581        self.__loop.exec_()
582        self.__loop.deleteLater()
583        self.__loop = None
584
585        action = self.__triggeredAction
586        self.__triggeredAction = None
587        return action
588
589    def hideEvent(self, event):
590        FramelessWindow.hideEvent(self, event)
591        if self.__loop:
592            self.__loop.exit()
593
594    def setCurrentPage(self, page):
595        self.__pages.setCurrentPage(page)
596
597    def setCurrentIndex(self, index):
598        self.__pages.setCurrentIndex(index)
599
600    def __onTriggered(self, action):
601        """Re-emit the action from the page.
602        """
603        self.__triggeredAction = action
604
605        # Hide and exit the event loop if necessary.
606        self.hide()
607        self.triggered.emit(action)
608
609    def triggerSearch(self):
610        self.__pages.setCurrentWidget(self.__suggestPage)
611        self.__search.setFocus(Qt.ShortcutFocusReason)
612
613        # Make sure that the first enabled item is set current.
614        self.__suggestPage.ensureCurrent()
615
616    def keyPressEvent(self, event):
617        if event.text():
618            # Ignore modifiers etc.
619            self.__search.setFocus(Qt.ShortcutFocusReason)
620            self.setCurrentIndex(0)
621            self.__search.keyPressEvent(event)
622
623        FramelessWindow.keyPressEvent(self, event)
624
625
626class ItemViewKeyNavigator(QObject):
627    def __init__(self, parent=None):
628        QObject.__init__(self, parent)
629        self.__view = None
630
631    def setView(self, view):
632        if self.__view != view:
633            self.__view = view
634
635    def view(self):
636        return self.__view
637
638    def eventFilter(self, obj, event):
639        etype = event.type()
640        if etype == QEvent.KeyPress:
641            key = event.key()
642            if key == Qt.Key_Down:
643                self.moveCurrent(1, 0)
644                return True
645            elif key == Qt.Key_Up:
646                self.moveCurrent(-1, 0)
647                return True
648            elif key == Qt.Key_Tab:
649                self.moveCurrent(0, 1)
650                return  True
651            elif key == Qt.Key_Enter or key == Qt.Key_Return:
652                self.activateCurrent()
653                return True
654
655        return QObject.eventFilter(self, obj, event)
656
657    def moveCurrent(self, rows, columns=0):
658        """Move the current index by rows, columns.
659        """
660        if self.__view is not None:
661            view = self.__view
662            model = view.model()
663
664            curr = view.currentIndex()
665            curr_row, curr_col = curr.row(), curr.column()
666
667            sign = 1 if rows >= 0 else -1
668            row = curr_row + rows
669
670            row_count = model.rowCount()
671            for i in range(row_count):
672                index = model.index((row + sign * i) % row_count, 0)
673                if index.flags() & Qt.ItemIsEnabled:
674                    view.setCurrentIndex(index)
675                    break
676            # TODO: move by columns
677
678    def activateCurrent(self):
679        """Activate the current index.
680        """
681        if self.__view is not None:
682            curr = self.__view.currentIndex()
683            if curr.isValid():
684                # TODO: Does this work
685                self.__view.activated.emit(curr)
686
687    def ensureCurrent(self):
688        """Ensure the view has a current item if one is available.
689        """
690        if self.__view is not None:
691            model = self.__view.model()
692            curr = self.__view.currentIndex()
693            if not curr.isValid():
694                for i in range(model.rowCount()):
695                    index = model.index(i, 0)
696                    if index.flags() & Qt.ItemIsEnabled:
697                        self.__view.setCurrentIndex(index)
698                        break
Note: See TracBrowser for help on using the repository browser.