source: orange/Orange/OrangeCanvas/canvas/quickmenu.py @ 11108:13f64ba800cb

Revision 11108:13f64ba800cb, 19.0 KB checked in by Ales Erjavec <ales.erjavec@…>, 19 months ago (diff)

Added a canvas QuickMenu toolbox popup widget.

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