source: orange/Orange/OrangeCanvas/canvas/quickmenu.py @ 11131:984471b6fa6e

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

Improved keyboard control and styling in quick menu.

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(Qt.BackgroundRole)
511            if brush.isValid():
512                brush = brush.toPyObject()
513                button = self.__pages.tabButton(i)
514                palette = button.palette()
515                button.setStyleSheet(
516                    "QToolButton {\n"
517                    "    qproperty-flat_: false;"
518                    "    background-color: %s;\n"
519                    "    border: none;\n"
520                    "}\n"
521                    "QToolButton:checked {\n"
522                    "    border: 1px solid %s;\n"
523                    "}" % (brush.color().name(),
524                           palette.color(palette.Mid).name())
525                )
526
527        self.__model = model
528        self.__suggestPage.setModel(model)
529
530    def popup(self, pos=None):
531        """Popup the menu at `pos` (in screen coordinates)..
532        """
533        if pos is None:
534            pos = QPoint()
535
536        self.__search.setText("")
537        self.__suggestPage.setFilterFixedString("")
538
539        self.ensurePolished()
540        size = self.sizeHint()
541        desktop = QApplication.desktop()
542        screen_geom = desktop.availableGeometry(pos)
543
544        # Adjust the size to fit inside the screen.
545        if size.height() > screen_geom.height():
546            size.setHeight(screen_geom.height())
547        if size.width() > screen_geom.width():
548            size.setWidth(screen_geom.width())
549
550        geom = QRect(pos, size)
551
552        if geom.top() < screen_geom.top():
553            geom.setTop(screen_geom.top())
554
555        if geom.left() < screen_geom.left():
556            geom.setLeft(screen_geom.left())
557
558        bottom_margin = screen_geom.bottom() - geom.bottom()
559        right_margin = screen_geom.right() - geom.right()
560        if bottom_margin < 0:
561            # Falls over the bottom of the screen, move it up.
562            geom.translate(0, bottom_margin)
563
564        # TODO: right to left locale
565        if right_margin < 0:
566            # Falls over the right screen edge, move the menu to the
567            # other side of pos.
568            geom.translate(-size.width(), 0)
569
570        self.setGeometry(geom)
571
572        self.show()
573
574    def exec_(self, pos=None):
575        self.popup(pos)
576        self.setFocus(Qt.PopupFocusReason)
577
578        self.__triggeredAction = None
579        self.__loop = QEventLoop(self)
580        self.__loop.exec_()
581        self.__loop.deleteLater()
582        self.__loop = None
583
584        action = self.__triggeredAction
585        self.__triggeredAction = None
586        return action
587
588    def hideEvent(self, event):
589        FramelessWindow.hideEvent(self, event)
590        if self.__loop:
591            self.__loop.exit()
592
593    def setCurrentPage(self, page):
594        self.__pages.setCurrentPage(page)
595
596    def setCurrentIndex(self, index):
597        self.__pages.setCurrentIndex(index)
598
599    def __onTriggered(self, action):
600        """Re-emit the action from the page.
601        """
602        self.__triggeredAction = action
603
604        # Hide and exit the event loop if necessary.
605        self.hide()
606        self.triggered.emit(action)
607
608    def triggerSearch(self):
609        self.__pages.setCurrentWidget(self.__suggestPage)
610        self.__search.setFocus(Qt.ShortcutFocusReason)
611
612        # Make sure that the first enabled item is set current.
613        self.__suggestPage.ensureCurrent()
614
615    def keyPressEvent(self, event):
616        if event.text():
617            # Ignore modifiers etc.
618            self.__search.setFocus(Qt.ShortcutFocusReason)
619            self.setCurrentIndex(0)
620            self.__search.keyPressEvent(event)
621
622        FramelessWindow.keyPressEvent(self, event)
623
624
625class ItemViewKeyNavigator(QObject):
626    def __init__(self, parent=None):
627        QObject.__init__(self, parent)
628        self.__view = None
629
630    def setView(self, view):
631        if self.__view != view:
632            self.__view = view
633
634    def view(self):
635        return self.__view
636
637    def eventFilter(self, obj, event):
638        etype = event.type()
639        if etype == QEvent.KeyPress:
640            key = event.key()
641            if key == Qt.Key_Down:
642                self.moveCurrent(1, 0)
643                return True
644            elif key == Qt.Key_Up:
645                self.moveCurrent(-1, 0)
646                return True
647            elif key == Qt.Key_Tab:
648                self.moveCurrent(0, 1)
649                return  True
650            elif key == Qt.Key_Enter or key == Qt.Key_Return:
651                self.activateCurrent()
652                return True
653
654        return QObject.eventFilter(self, obj, event)
655
656    def moveCurrent(self, rows, columns=0):
657        """Move the current index by rows, columns.
658        """
659        if self.__view is not None:
660            view = self.__view
661            model = view.model()
662
663            curr = view.currentIndex()
664            curr_row, curr_col = curr.row(), curr.column()
665
666            sign = 1 if rows >= 0 else -1
667            row = curr_row + rows
668
669            row_count = model.rowCount()
670            for i in range(row_count):
671                index = model.index((row + sign * i) % row_count, 0)
672                if index.flags() & Qt.ItemIsEnabled:
673                    view.setCurrentIndex(index)
674                    break
675            # TODO: move by columns
676
677    def activateCurrent(self):
678        """Activate the current index.
679        """
680        if self.__view is not None:
681            curr = self.__view.currentIndex()
682            if curr.isValid():
683                # TODO: Does this work
684                self.__view.activated.emit(curr)
685
686    def ensureCurrent(self):
687        """Ensure the view has a current item if one is available.
688        """
689        if self.__view is not None:
690            model = self.__view.model()
691            curr = self.__view.currentIndex()
692            if not curr.isValid():
693                for i in range(model.rowCount()):
694                    index = model.index(i, 0)
695                    if index.flags() & Qt.ItemIsEnabled:
696                        self.__view.setCurrentIndex(index)
697                        break
Note: See TracBrowser for help on using the repository browser.