source: orange/Orange/OrangeCanvas/application/canvastooldock.py @ 11502:66682f793dbd

Revision 11502:66682f793dbd, 17.3 KB checked in by Ales Erjavec <ales.erjavec@…>, 11 months ago (diff)

Fixed placement of popup category menu widgets.

They now popup to the right or left of the toolbar.

RevLine 
[11120]1"""
2Orange Canvas Tool Dock widget
3
4"""
[11495]5import sys
6
[11120]7from PyQt4.QtGui import (
8    QWidget, QSplitter, QVBoxLayout, QTextEdit, QAction, QPalette,
[11495]9    QSizePolicy, QApplication, QDrag
[11120]10)
11
[11495]12from PyQt4.QtCore import (
[11502]13    Qt, QSize, QObject, QPropertyAnimation, QEvent, QRect, QPoint,
[11495]14    QModelIndex, QPersistentModelIndex, QEventLoop, QMimeData
15)
16
17from PyQt4.QtCore import pyqtProperty as Property, pyqtSignal as Signal
[11120]18
19from ..gui.toolgrid import ToolGrid
20from ..gui.toolbar import DynamicResizeToolBar
[11243]21from ..gui.quickhelp import QuickHelp
[11495]22from ..gui.framelesswindow import FramelessWindow
23from ..document.quickmenu import MenuPage
[11120]24from .widgettoolbox import WidgetToolBox, iter_item
[11243]25
[11133]26from ..registry.qt import QtWidgetRegistry
[11495]27from ..utils.qtcompat import toPyObject
[11120]28
29
30class SplitterResizer(QObject):
31    """An object able to control the size of a widget in a
32    QSpliter instance.
33
34    """
35
36    def __init__(self, parent=None):
37        QObject.__init__(self, parent)
38        self.__splitter = None
39        self.__widget = None
40        self.__animationEnabled = True
41        self.__size = -1
42        self.__expanded = False
43        self.__animation = QPropertyAnimation(self, "size_", self)
44
45        self.__action = QAction("toogle-expanded", self, checkable=True)
46        self.__action.triggered[bool].connect(self.setExpanded)
47
48    def setSize(self, size):
49        """Set the size of the controlled widget (either width or height
50        depending on the orientation).
51
52        """
53        if self.__size != size:
54            self.__size = size
55            self.__update()
56
57    def size(self):
58        """Return the size of the widget in the splitter (either height of
59        width) depending on the splitter orientation.
60
61        """
62        if self.__splitter and self.__widget:
63            index = self.__splitter.indexOf(self.__widget)
64            sizes = self.__splitter.sizes()
65            return sizes[index]
66        else:
67            return -1
68
69    size_ = Property(int, fget=size, fset=setSize)
70
71    def setAnimationEnabled(self, enable):
72        """Enable/disable animation.
73        """
74        self.__animation.setDuration(0 if enable else 200)
75
76    def animationEnabled(self):
77        return self.__animation.duration() == 0
78
79    def setSplitterAndWidget(self, splitter, widget):
80        """Set the QSplitter and QWidget instance the resizer should control.
81
82        .. note:: the widget must be in the splitter.
83
84        """
85        if splitter and widget and not splitter.indexOf(widget) > 0:
86            raise ValueError("Widget must be in a spliter.")
87
88        if self.__widget:
89            self.__widget.removeEventFilter()
90        self.__splitter = splitter
91        self.__widget = widget
92
93        if widget:
94            widget.installEventFilter(self)
95
96        self.__update()
97
98    def toogleExpandedAction(self):
99        """Return a QAction that can be used to toggle expanded state.
100        """
101        return self.__action
102
103    def open(self):
104        """Open the controlled widget (expand it to it sizeHint).
105        """
106        self.__expanded = True
[11242]107        self.__action.setChecked(True)
[11120]108
109        if not (self.__splitter and self.__widget):
110            return
111
112        size = self.size()
113        if size > 0:
114            # Already has non zero size.
115            return
116
117        hint = self.__widget.sizeHint()
118
119        if self.__splitter.orientation() == Qt.Vertical:
120            end = hint.height()
121        else:
122            end = hint.width()
123
124        self.__animation.setStartValue(0)
125        self.__animation.setEndValue(end)
126        self.__animation.start()
127
128    def close(self):
129        """Close the controlled widget (shrink to size 0).
130        """
131        self.__expanded = False
[11242]132        self.__action.setChecked(False)
[11120]133
134        if not (self.__splitter and self.__widget):
135            return
136
137        self.__animation.setStartValue(self.size())
138        self.__animation.setEndValue(0)
139        self.__animation.start()
140
141    def setExpanded(self, expanded):
142        """Set the expanded state.
143
144        """
145        if self.__expanded != expanded:
146            if expanded:
147                self.open()
148            else:
149                self.close()
150
151    def expanded(self):
152        """Return the expanded state.
153        """
154        return self.__expanded
155
156    def __update(self):
157        """Update the splitter sizes.
158        """
159        if self.__splitter and self.__widget:
160            splitter = self.__splitter
161            index = splitter.indexOf(self.__widget)
162            sizes = splitter.sizes()
163            current = sizes[index]
164            diff = current - self.__size
165            sizes[index] = self.__size
166            sizes[index - 1] = sizes[index - 1] + diff
167
168            self.__splitter.setSizes(sizes)
169
170    def eventFilter(self, obj, event):
171        if event.type() == QEvent.Resize:
172            if self.__splitter.orientation() == Qt.Vertical:
173                size = event.size().height()
174            else:
175                size = event.size().width()
176
177            if self.__expanded and size == 0:
178                self.__action.setChecked(False)
179                self.__expanded = False
180            elif not self.__expanded and size > 0:
181                self.__action.setChecked(True)
182                self.__expanded = True
183
184        return QObject.eventFilter(self, obj, event)
185
186
[11243]187class QuickHelpWidget(QuickHelp):
[11120]188    def minimumSizeHint(self):
189        """Reimplemented to allow the Splitter to resize the widget
190        with a continuous animation.
191
192        """
193        hint = QTextEdit.minimumSizeHint(self)
194        return QSize(hint.width(), 0)
195
196
197class CanvasToolDock(QWidget):
198    """Canvas dock widget with widget toolbox, quick help and
199    canvas actions.
200
201    """
202    def __init__(self, parent=None, **kwargs):
203        QWidget.__init__(self, parent, **kwargs)
204
205        self.__setupUi()
206
207    def __setupUi(self):
208        layout = QVBoxLayout()
209        layout.setContentsMargins(0, 0, 0, 0)
210        layout.setSpacing(0)
211
212        self.toolbox = WidgetToolBox()
213
214        self.help = QuickHelpWidget(objectName="quick-help")
215
216        self.__splitter = QSplitter()
217        self.__splitter.setOrientation(Qt.Vertical)
218
219        self.__splitter.addWidget(self.toolbox)
220        self.__splitter.addWidget(self.help)
221
222        self.toolbar = DynamicResizeToolBar()
223        self.toolbar.setMovable(False)
224        self.toolbar.setFloatable(False)
225
226        self.toolbar.setSizePolicy(QSizePolicy.Ignored,
227                                   QSizePolicy.Preferred)
228
229        layout.addWidget(self.__splitter, 10)
230        layout.addWidget(self.toolbar)
231
232        self.setLayout(layout)
233        self.__splitterResizer = SplitterResizer()
234        self.__splitterResizer.setSplitterAndWidget(self.__splitter, self.help)
235
236    def setQuickHelpVisible(self, state):
237        """Set the quick help box visibility status.
238        """
239        self.__splitterResizer.setExpanded(state)
240
241    def quickHelpVisible(self):
242        return self.__splitterResizer.expanded()
243
244    def setQuickHelpAnimationEnabled(self, enabled):
245        """Enable/disable the quick help animation.
246        """
247        self.__splitterResizer.setAnimationEnabled(enabled)
248
249    def toogleQuickHelpAction(self):
250        """Return a checkable QAction for help show/hide.
251        """
252        return self.__splitterResizer.toogleExpandedAction()
253
254
255class QuickCategoryToolbar(ToolGrid):
256    """A toolbar with category buttons.
257    """
258    def __init__(self, parent=None, buttonSize=None, iconSize=None):
259        ToolGrid.__init__(self, parent, 1, buttonSize, iconSize,
260                          Qt.ToolButtonIconOnly)
261        self.__model = None
262
263    def setColumnCount(self, count):
264        raise Exception("Cannot set the column count on a Toolbar")
265
266    def setModel(self, model):
267        """Set the registry model.
268        """
269        if self.__model is not None:
270            self.__model.itemChanged.disconnect(self.__on_itemChanged)
271            self.__model.rowsInserted.disconnect(self.__on_rowsInserted)
272            self.__model.rowsRemoved.disconnect(self.__on_rowsRemoved)
273            self.clear()
274
275        self.__model = model
276        if self.__model is not None:
277            self.__model.itemChanged.connect(self.__on_itemChanged)
278            self.__model.rowsInserted.connect(self.__on_rowsInserted)
279            self.__model.rowsRemoved.connect(self.__on_rowsRemoved)
280            self.__initFromModel(model)
281
282    def __initFromModel(self, model):
283        """Initialize the toolbar from the model.
284        """
285        root = model.invisibleRootItem()
286        for item in iter_item(root):
287            action = self.createActionForItem(item)
288            self.addAction(action)
289
290    def createActionForItem(self, item):
291        """Create the QAction instance for item.
292        """
293        action = QAction(item.icon(), item.text(), self,
294                         toolTip=item.toolTip())
295        action.setData(item)
296        return action
297
298    def createButtonForAction(self, action):
299        """Create a button for the action.
300        """
301        button = ToolGrid.createButtonForAction(self, action)
302
303        item = action.data().toPyObject()
[11133]304        if item.data(Qt.BackgroundRole).isValid():
305            brush = item.background()
306        elif item.data(QtWidgetRegistry.BACKGROUND_ROLE).isValid():
307            brush = item.data(QtWidgetRegistry.BACKGROUND_ROLE).toPyObject()
308        else:
309            brush = self.palette().brush(QPalette.Button)
310
[11120]311        palette = button.palette()
312        palette.setColor(QPalette.Button, brush.color())
313        palette.setColor(QPalette.Window, brush.color())
314        button.setPalette(palette)
315        button.setProperty("quick-category-toolbutton", True)
316
317        style_sheet = ("QToolButton {\n"
318                       "    background-color: %s;\n"
319                       "    border: none;\n"
320                       "    border-bottom: 1px solid palette(dark);\n"
321                       "}")
322        button.setStyleSheet(style_sheet % brush.color().name())
323
324        return button
325
326    def __on_itemChanged(self, item):
327        root = self.__model.invisibleRootItem()
328        if item.parentItem() == root:
329            row = item.row()
330            action = self._gridSlots[row].action
331            action.setText(item.text())
332            action.setIcon(item.icon())
333            action.setToolTip(item.toolTip())
334
335    def __on_rowsInserted(self, parent, start, end):
336        root = self.__model.invisibleRootItem()
337        if root == parent:
338            for index in range(start, end + 1):
339                item = parent.child(index)
340                self.addAction(self.createActionForItem(item))
341
342    def __on_rowsRemoved(self, parent, start, end):
343        root = self.__model.invisibleRootItem()
344        if root == parent:
345            for index in range(end, start - 1, -1):
346                action = self._gridSlots[index].action
347                self.removeAction(action)
[11495]348
349
350class CategoryPopupMenu(FramelessWindow):
351    triggered = Signal(QAction)
352    hovered = Signal(QAction)
353
354    def __init__(self, parent=None, **kwargs):
355        FramelessWindow.__init__(self, parent, **kwargs)
356        self.setWindowFlags(self.windowFlags() | Qt.Popup)
357
358        layout = QVBoxLayout()
359        layout.setContentsMargins(6, 6, 6, 6)
360
361        self.__menu = MenuPage()
362        self.__menu.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE)
363
364        if sys.platform == "darwin":
365            self.__menu.view().setAttribute(Qt.WA_MacShowFocusRect, False)
366
367        self.__menu.triggered.connect(self.__onTriggered)
368        self.__menu.hovered.connect(self.hovered)
369
370        self.__dragListener = ItemViewDragStartEventListener(self)
371        self.__dragListener.dragStarted.connect(self.__onDragStarted)
372
373        self.__menu.view().viewport().installEventFilter(self.__dragListener)
374
375        layout.addWidget(self.__menu)
376
377        self.setLayout(layout)
378
379        self.__action = None
380        self.__loop = None
381        self.__item = None
382
383    def setCategoryItem(self, item):
384        """
385        Set the category root item (:class:`QStandardItem`).
386        """
387        self.__item = item
388        model = item.model()
389        self.__menu.setModel(model)
390        self.__menu.setRootIndex(item.index())
391
392    def popup(self, pos=None):
393        if pos is None:
394            pos = self.pos()
395        geom = widget_popup_geometry(pos, self)
396        self.setGeometry(geom)
397        self.show()
398
399    def exec_(self, pos=None):
400        self.popup(pos)
401        self.__loop = QEventLoop()
402
403        self.__action = None
404        self.__loop.exec_()
405        self.__loop = None
406
407        if self.__action is not None:
408            action = self.__action
409        else:
410            action = None
411        return action
412
413    def hideEvent(self, event):
414        if self.__loop is not None:
415            self.__loop.exit(0)
416
417        return FramelessWindow.hideEvent(self, event)
418
419    def __onTriggered(self, action):
420        self.__action = action
421        self.triggered.emit(action)
422        self.hide()
423
424        if self.__loop:
425            self.__loop.exit(0)
426
427    def __onDragStarted(self, index):
428        desc = toPyObject(index.data(QtWidgetRegistry.WIDGET_DESC_ROLE))
429        icon = toPyObject(index.data(Qt.DecorationRole))
430
431        drag_data = QMimeData()
432        drag_data.setData(
433            "application/vnv.orange-canvas.registry.qualified-name",
434            desc.qualified_name
435        )
436        drag = QDrag(self)
437        drag.setPixmap(icon.pixmap(38))
438        drag.setMimeData(drag_data)
439
440        # TODO: Should animate (accept) hide.
441        self.hide()
442
443        # When a drag is started and the menu hidden the item's tool tip
444        # can still show for a short time UNDER the cursor preventing a
445        # drop.
446        viewport = self.__menu.view().viewport()
447        filter = ToolTipEventFilter()
448        viewport.installEventFilter(filter)
449
450        drag.exec_(Qt.CopyAction)
451
452        viewport.removeEventFilter(filter)
453
454
455class ItemViewDragStartEventListener(QObject):
456    dragStarted = Signal(QModelIndex)
457
458    def __init__(self, parent=None):
459        QObject.__init__(self, parent)
460        self._pos = None
461        self._index = None
462
463    def eventFilter(self, viewport, event):
464        view = viewport.parent()
465
466        if event.type() == QEvent.MouseButtonPress and \
467                event.button() == Qt.LeftButton:
468
469            index = view.indexAt(event.pos())
470
471            if index is not None:
472                self._pos = event.pos()
473                self._index = QPersistentModelIndex(index)
474
475        elif event.type() == QEvent.MouseMove and self._pos is not None and \
476                ((self._pos - event.pos()).manhattanLength() >=
477                 QApplication.startDragDistance()):
478
479            if self._index.isValid():
480                # Map to a QModelIndex in the model.
481                index = self._index
482                index = index.model().index(index.row(), index.column(),
483                                            index.parent())
484                self._pos = None
485                self._index = None
486
487                self.dragStarted.emit(index)
488
489        return QObject.eventFilter(self, view, event)
490
491
492class ToolTipEventFilter(QObject):
493    def eventFilter(self, receiver, event):
494        if event.type() == QEvent.ToolTip:
495            return True
496
497        return QObject.eventFilter(self, receiver, event)
498
499
500def widget_popup_geometry(pos, widget):
501    widget.ensurePolished()
502
503    if widget.testAttribute(Qt.WA_Resized):
504        size = widget.size()
505    else:
506        size = widget.sizeHint()
507
508    desktop = QApplication.desktop()
509    screen_geom = desktop.availableGeometry(pos)
510
511    # Adjust the size to fit inside the screen.
512    if size.height() > screen_geom.height():
513        size.setHeight(screen_geom.height())
514    if size.width() > screen_geom.width():
515        size.setWidth(screen_geom.width())
516
517    geom = QRect(pos, size)
518
519    if geom.top() < screen_geom.top():
520        geom.setTop(screen_geom.top())
521
522    if geom.left() < screen_geom.left():
523        geom.setLeft(screen_geom.left())
524
525    bottom_margin = screen_geom.bottom() - geom.bottom()
526    right_margin = screen_geom.right() - geom.right()
527    if bottom_margin < 0:
528        # Falls over the bottom of the screen, move it up.
529        geom.translate(0, bottom_margin)
530
531    # TODO: right to left locale
532    if right_margin < 0:
533        # Falls over the right screen edge, move the menu to the
534        # other side of pos.
535        geom.translate(-size.width(), 0)
536
537    return geom
[11502]538
539
540def popup_position_from_source(popup, source, orientation=Qt.Vertical):
541    popup.ensurePolished()
542    source.ensurePolished()
543
544    if popup.testAttribute(Qt.WA_Resized):
545        size = popup.size()
546    else:
547        size = popup.sizeHint()
548
549    desktop = QApplication.desktop()
550    screen_geom = desktop.availableGeometry(source)
551    source_rect = QRect(source.mapToGlobal(QPoint(0, 0)), source.size())
552
553    if orientation == Qt.Vertical:
554        if source_rect.right() + size.width() < screen_geom.width():
555            x = source_rect.right()
556        else:
557            x = source_rect.left() - size.width()
558
559        # bottom overflow
560        dy = source_rect.top() + size.height() - screen_geom.height()
561        if dy < 0:
562            y = source_rect.top()
563        else:
564            y = source_rect.top() - dy
565    else:
566        # right overflow
567        dx = source_rect.left() + size.width() - screen_geom.width()
568        if dx < 0:
569            x = source_rect.left()
570        else:
571            x = source_rect.left() - dx
572
573        if source_rect.bottom() + size.height() < screen_geom.height():
574            y = source_rect.bottom()
575        else:
576            y = source_rect.top() - size.height()
577
578    return QPoint(x, y)
Note: See TracBrowser for help on using the repository browser.