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.

Line 
1"""
2Orange Canvas Tool Dock widget
3
4"""
5import sys
6
7from PyQt4.QtGui import (
8    QWidget, QSplitter, QVBoxLayout, QTextEdit, QAction, QPalette,
9    QSizePolicy, QApplication, QDrag
10)
11
12from PyQt4.QtCore import (
13    Qt, QSize, QObject, QPropertyAnimation, QEvent, QRect, QPoint,
14    QModelIndex, QPersistentModelIndex, QEventLoop, QMimeData
15)
16
17from PyQt4.QtCore import pyqtProperty as Property, pyqtSignal as Signal
18
19from ..gui.toolgrid import ToolGrid
20from ..gui.toolbar import DynamicResizeToolBar
21from ..gui.quickhelp import QuickHelp
22from ..gui.framelesswindow import FramelessWindow
23from ..document.quickmenu import MenuPage
24from .widgettoolbox import WidgetToolBox, iter_item
25
26from ..registry.qt import QtWidgetRegistry
27from ..utils.qtcompat import toPyObject
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
107        self.__action.setChecked(True)
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
132        self.__action.setChecked(False)
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
187class QuickHelpWidget(QuickHelp):
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()
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
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)
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
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.