source: orange/Orange/OrangeCanvas/canvas/quickmenu.py @ 11110:97cbbfd21d41

Revision 11110:97cbbfd21d41, 19.1 KB checked in by Ales Erjavec <ales.erjavec@…>, 18 months ago (diff)

Fixed undefined local variable error.

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