Commits

Aleš Erjavec committed cf6f674

Added gui widget toolkit.

  • Participants
  • Parent commits a98ee71

Comments (0)

Files changed (16)

Orange/OrangeCanvas/gui/__init__.py

+"""
+===========
+GUI toolkit
+===========
+
+A GUI toolkit with widgets used by other parts of Orange Canvas.
+
+Extends basic Qt classes with extra functionality.
+
+"""

Orange/OrangeCanvas/gui/dock.py

+"""
+=======================
+Collapsible Dock Widget
+=======================
+
+A dock widget with a header that can be a collapsed/expanded.
+
+"""
+
+import logging
+
+from PyQt4.QtGui import (
+    QDockWidget, QAbstractButton, QSizePolicy, QStyle, QIcon, QTransform
+)
+
+from PyQt4.QtCore import Qt, QEvent
+
+from PyQt4.QtCore import pyqtProperty as Property
+
+from .stackedwidget import AnimatedStackedWidget
+
+log = logging.getLogger(__name__)
+
+
+class CollapsibleDockWidget(QDockWidget):
+    """A Dock widget for which the close action collapses the widget
+    to a smaller size.
+
+    """
+    def __init__(self, *args, **kwargs):
+        QDockWidget.__init__(self, *args, **kwargs)
+
+        self.__expandedWidget = None
+        self.__collapsedWidget = None
+        self.__expanded = True
+
+        self.setFeatures(QDockWidget.DockWidgetClosable | \
+                         QDockWidget.DockWidgetMovable)
+        self.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
+
+        self.featuresChanged.connect(self.__onFeaturesChanged)
+        self.dockLocationChanged.connect(self.__onDockLocationChanged)
+
+        # Use the toolbar horizontal extension button icon as the default
+        # for the expand/collapse button
+        pm = self.style().standardPixmap(
+                    QStyle.SP_ToolBarHorizontalExtensionButton
+                )
+
+        # Rotate the icon
+        transform = QTransform()
+        transform.rotate(180)
+
+        pm_rev = pm.transformed(transform)
+
+        self.__iconRight = QIcon(pm)
+        self.__iconLeft = QIcon(pm_rev)
+
+        close = self.findChild(QAbstractButton,
+                               name="qt_dockwidget_closebutton")
+
+        close.installEventFilter(self)
+        self.__closeButton = close
+
+        self.__stack = AnimatedStackedWidget()
+
+        self.__stack.setSizePolicy(QSizePolicy.Fixed,
+                                   QSizePolicy.Expanding)
+
+        self.__stack.transitionStarted.connect(self._onTransitionStarted)
+        self.__stack.transitionFinished.connect(self._onTransitionFinished)
+
+        self.__stack.installEventFilter(self)
+
+        QDockWidget.setWidget(self, self.__stack)
+
+        self.__closeButton.setIcon(self.__iconLeft)
+
+    def setExpanded(self, state):
+        """Set the expanded state.
+        """
+        if self.__expanded != state:
+            self.__expanded = state
+            if state and self.__expandedWidget is not None:
+                log.debug("Dock expanding.")
+                self.__stack.setCurrentWidget(self.__expandedWidget)
+            elif not state and self.__collapsedWidget is not None:
+                log.debug("Dock collapsing.")
+                self.__stack.setCurrentWidget(self.__collapsedWidget)
+            self.__fixIcon()
+
+    def expanded(self):
+        """Is the dock widget in expanded state
+        """
+        return self.__expanded
+
+    expanded_ = Property(bool, fset=setExpanded, fget=expanded)
+
+    def setWidget(self, w):
+        raise NotImplementedError(
+                "Please use the setExpandedWidget/setCollapsedWidget method."
+              )
+
+    def setExpandedWidget(self, widget):
+        """Set the widget with contents to show while expanded.
+        """
+        if widget is self.__expandedWidget:
+            return
+
+        if self.__expandedWidget is not None:
+            self.__stack.removeWidget(self.__expandedWidget)
+
+        self.__stack.insertWidget(0, widget)
+        self.__expandedWidget = widget
+
+        if self.__expanded:
+            self.__stack.setCurrentWidget(widget)
+
+    def setCollapsedWidget(self, widget):
+        """Set the widget with contents to show while collapsed.
+        """
+        if widget is self.__collapsedWidget:
+            return
+
+        if self.__collapsedWidget is not None:
+            self.__stack.removeWidget(self.__collapsedWidget)
+
+        self.__stack.insertWidget(1, widget)
+        self.__collapsedWidget = widget
+
+        if not self.__expanded:
+            self.__stack.setCurrentWidget(widget)
+
+    def setAnimationEnabled(self, animationEnabled):
+        """Enable/disable the transition animation.
+        """
+        self.__stack.setAnimationEnabled(animationEnabled)
+
+    def animationEnabled(self):
+        return self.__stack.animationEnabled()
+
+    def currentWidget(self):
+        """Return the current widget.
+        """
+        if self.__expanded:
+            return self.__expandedWidget
+        else:
+            return self.__collapsedWidget
+
+    def _setExpandedState(self, state):
+        """Set the expanded/collapsed state. `True` indicates an
+        expanded state.
+
+        """
+        if state and not self.__expanded:
+            self.expand()
+        elif not state and self.__expanded:
+            self.collapse()
+
+    def expand(self):
+        """Expand the dock (same as `setExpanded(True)`)
+        """
+        self.setExpanded(True)
+
+    def collapse(self):
+        """Collapse the dock (same as `setExpanded(False)`)
+        """
+        self.setExpanded(False)
+
+    def eventFilter(self, obj, event):
+        if obj is self.__closeButton:
+            type = event.type()
+            if type == QEvent.MouseButtonPress:
+                self.setExpanded(not self.__expanded)
+                return True
+            elif type == QEvent.MouseButtonDblClick or \
+                    type == QEvent.MouseButtonRelease:
+                return True
+            # TODO: which other events can trigger the button (is the button
+            # focusable).
+
+        if obj is self.__stack:
+            type = event.type()
+            if type == QEvent.Resize:
+                # If the stack resizes
+                obj.resizeEvent(event)
+                size = event.size()
+                size = self.__stack.sizeHint()
+                if size.width() > 0:
+                    left, _, right, _ = self.getContentsMargins()
+                    self.setFixedWidth(size.width() + left + right)
+                return True
+
+        return QDockWidget.eventFilter(self, obj, event)
+
+    def __onFeaturesChanged(self, features):
+        pass
+
+    def __onDockLocationChanged(self, area):
+        if area == Qt.LeftDockWidgetArea:
+            self.setLayoutDirection(Qt.LeftToRight)
+        else:
+            self.setLayoutDirection(Qt.RightToLeft)
+
+        self.__stack.setLayoutDirection(self.parentWidget().layoutDirection())
+        self.__fixIcon()
+
+    def _onTransitionStarted(self):
+        self.__stack.installEventFilter(self)
+
+    def _onTransitionFinished(self):
+        self.__stack.removeEventFilter(self)
+        size = self.__stack.sizeHint()
+        left, _, right, _ = self.getContentsMargins()
+        self.setFixedWidth(size.width() + left + right)
+        log.debug("Dock transition finished (new width %i)", size.width())
+
+    def __fixIcon(self):
+        """Fix the dock close icon.
+        """
+        direction = self.layoutDirection()
+        if direction == Qt.LeftToRight:
+            if self.__expanded:
+                icon = self.__iconLeft
+            else:
+                icon = self.__iconRight
+        else:
+            if self.__expanded:
+                icon = self.__iconRight
+            else:
+                icon = self.__iconLeft
+
+        self.__closeButton.setIcon(icon)

Orange/OrangeCanvas/gui/dropshadow.py

+"""
+A DropShadowWidget
+
+"""
+
+from PyQt4.QtGui import (
+    QWidget, QPainter, QPixmap, QGraphicsScene, QGraphicsRectItem,
+    QGraphicsDropShadowEffect, QColor, QPen, QPalette, QStyleOption,
+    QAbstractScrollArea, QToolBar
+)
+
+from PyQt4.QtCore import (
+    Qt, QPoint, QPointF, QRect, QRectF, QSize, QSizeF, QEvent
+)
+
+from PyQt4.QtCore import pyqtProperty as Property
+
+CACHED_SHADOW_RECT_SIZE = (50, 50)
+
+
+def render_drop_shadow_frame(pixmap, shadow_rect, shadow_color,
+                             offset, radius, rect_fill_color):
+    pixmap.fill(QColor(0, 0, 0, 0))
+    scene = QGraphicsScene()
+    rect = QGraphicsRectItem(shadow_rect)
+    rect.setBrush(QColor(rect_fill_color))
+    rect.setPen(QPen(Qt.NoPen))
+    scene.addItem(rect)
+    effect = QGraphicsDropShadowEffect(color=shadow_color,
+                                       blurRadius=radius,
+                                       offset=offset)
+
+    rect.setGraphicsEffect(effect)
+    scene.setSceneRect(QRectF(QPointF(0, 0), QSizeF(pixmap.size())))
+    painter = QPainter(pixmap)
+    scene.render(painter)
+    painter.end()
+    scene.clear()
+    scene.deleteLater()
+    return pixmap
+
+
+class DropShadowFrame(QWidget):
+    """A widget drawing a drop shadow effect around the geometry of
+    another widget (similar to QFocusFrame).
+
+    """
+    def __init__(self, parent=None, color=None, radius=5,
+                 **kwargs):
+        QWidget.__init__(self, parent, **kwargs)
+        self.setAttribute(Qt.WA_TransparentForMouseEvents, True)
+        self.setAttribute(Qt.WA_NoChildEventsForParent, True)
+        self.setFocusPolicy(Qt.NoFocus)
+
+        if color is None:
+            color = self.palette().color(QPalette.Dark)
+
+        self.__color = color
+        self.__radius = radius
+
+        self.__widget = None
+        self.__widgetParent = None
+        self.__updatePixmap()
+
+    def setColor(self, color):
+        """Set the color of the shadow.
+        """
+        if not isinstance(color, QColor):
+            color = QColor(color)
+
+        if self.__color != color:
+            self.__color = color
+            self.__updatePixmap()
+
+    def color(self):
+        return self.__color
+
+    color_ = Property(QColor, fget=color, fset=setColor, designable=True)
+
+    def setRadius(self, radius):
+        """Set the drop shadow blur radius.
+        """
+        if self.__radius != radius:
+            self.__radius = radius
+            self.__updateGeometry()
+            self.__updatePixmap()
+
+    def radius(self):
+        return self.__radius
+
+    radius_ = Property(int, fget=radius, fset=setRadius, designable=True)
+
+    def setWidget(self, widget):
+        """Set the widget to show the shadow around.
+        """
+        if self.__widget:
+            self.__widget.removeEventFilter(self)
+
+        self.__widget = widget
+
+        if self.__widget:
+            self.__widget.installEventFilter(self)
+            # Find the parent for the frame
+            # This is the top level window a toolbar or a viewport
+            # of a scroll area
+            parent = widget.parentWidget()
+            while not (isinstance(parent, (QAbstractScrollArea, QToolBar)) or \
+                       parent.isWindow()):
+                parent = parent.parentWidget()
+
+            if isinstance(parent, QAbstractScrollArea):
+                parent = parent.viewport()
+
+            self.__widgetParent = parent
+            self.setParent(parent)
+            self.stackUnder(widget)
+            self.__updateGeometry()
+            self.setVisible(widget.isVisible())
+
+    def widget(self):
+        """Return the widget taht was set by `setWidget`.
+        """
+        return self.__widget
+
+    def paintEvent(self, event):
+        # TODO: Use QPainter.drawPixmapFragments on Qt 4.7
+        opt = QStyleOption()
+        opt.initFrom(self)
+
+        pixmap = self.__shadowPixmap
+
+        shadow_rect = QRectF(opt.rect)
+        widget_rect = QRectF(self.widget().geometry())
+        widget_rect.moveTo(self.radius_, self.radius_)
+
+        left = top = right = bottom = self.radius_
+        pixmap_rect = QRectF(QPointF(0, 0), QSizeF(pixmap.size()))
+
+        # Shadow casting rectangle in the source pixmap.
+        pixmap_shadow_rect = pixmap_rect.adjusted(left, top, -right, -bottom)
+        source_rects = self.__shadowPixmapFragments(pixmap_rect,
+                                                   pixmap_shadow_rect)
+        target_rects = self.__shadowPixmapFragments(shadow_rect, widget_rect)
+
+        painter = QPainter(self)
+        for source, target in zip(source_rects, target_rects):
+            painter.drawPixmap(target, pixmap, source)
+        painter.end()
+
+    def eventFilter(self, obj, event):
+        etype = event.type()
+        if etype == QEvent.Move or etype == QEvent.Resize:
+            self.__updateGeometry()
+        elif etype == QEvent.Show:
+            self.__updateGeometry()
+            self.show()
+        elif etype == QEvent.Hide:
+            self.hide()
+        return False
+
+    def __updateGeometry(self):
+        """Update the shadow geometry to fit the widget's changed
+        geometry.
+        """
+        widget = self.__widget
+        parent = self.__widgetParent
+        radius = self.radius_
+        pos = widget.pos()
+        if parent != widget.parentWidget():
+            pos = widget.parentWidget().mapTo(parent, pos)
+
+        geom = QRect(pos, widget.size())
+        geom.adjust(-radius, -radius, radius, radius)
+        if geom != self.geometry():
+            self.setGeometry(geom)
+
+    def __updatePixmap(self):
+        """Update the cached shadow pixmap.
+        """
+        rect_size = QSize(50, 50)
+        left = top = right = bottom = self.radius_
+
+        # Size of the pixmap.
+        pixmap_size = QSize(rect_size.width() + left + right,
+                            rect_size.height() + top + bottom)
+        shadow_rect = QRect(QPoint(left, top), rect_size)
+        pixmap = QPixmap(pixmap_size)
+        pixmap.fill(QColor(0, 0, 0, 0))
+        rect_fill_color = self.palette().color(QPalette.Window)
+
+        pixmap = render_drop_shadow_frame(
+                      pixmap,
+                      QRectF(shadow_rect),
+                      shadow_color=self.color_,
+                      offset=QPointF(0, 0),
+                      radius=self.radius_,
+                      rect_fill_color=rect_fill_color
+                      )
+
+        self.__shadowPixmap = pixmap
+        self.update()
+
+    def __shadowPixmapFragments(self, pixmap_rect, shadow_rect):
+        """Return a list of 8 QRectF fragments for drawing a shadow.
+        """
+        s_left, s_top, s_right, s_bottom = \
+            shadow_rect.left(), shadow_rect.top(), \
+            shadow_rect.right(), shadow_rect.bottom()
+        s_width, s_height = shadow_rect.width(), shadow_rect.height()
+        p_width, p_height = pixmap_rect.width(), pixmap_rect.height()
+
+        top_left = QRectF(0.0, 0.0, s_left, s_top)
+        top = QRectF(s_left, 0.0, s_width, s_top)
+        top_right = QRectF(s_right, 0.0, p_width - s_width, s_top)
+        right = QRectF(s_right, s_top, p_width - s_right, s_height)
+        right_bottom = QRectF(shadow_rect.bottomRight(),
+                              pixmap_rect.bottomRight())
+        bottom = QRectF(shadow_rect.bottomLeft(),
+                        pixmap_rect.bottomRight() - \
+                        QPointF(p_width - s_right, 0.0))
+        bottom_left = QRectF(shadow_rect.bottomLeft() - QPointF(s_left, 0.0),
+                             pixmap_rect.bottomLeft() + QPointF(s_left, 0.0))
+        left = QRectF(pixmap_rect.topLeft() + QPointF(0.0, s_top),
+                      shadow_rect.bottomLeft())
+        return [top_left, top, top_right, right, right_bottom,
+                bottom, bottom_left, left]
+
+
+# A different obsolete implementation
+
+class _DropShadowWidget(QWidget):
+    """A frame widget drawing a drop shadow effect around its
+    contents.
+
+    """
+    def __init__(self, parent=None, offset=None, radius=None,
+                 color=None, **kwargs):
+        QWidget.__init__(self, parent, **kwargs)
+
+        # Bypass the overloaded method to set the default margins.
+        QWidget.setContentsMargins(self, 10, 10, 10, 10)
+
+        if offset is None:
+            offset = QPointF(0., 0.)
+        if radius is None:
+            radius = 20
+        if color is None:
+            color = QColor(Qt.black)
+
+        self.offset = offset
+        self.radius = radius
+        self.color = color
+        self._shadowPixmap = None
+        self._updateShadowPixmap()
+
+    def setOffset(self, offset):
+        """Set the drop shadow offset (`QPoint`)
+        """
+        self.offset = offset
+        self._updateShadowPixmap()
+        self.update()
+
+    def setRadius(self, radius):
+        """Set the drop shadow blur radius (`float`).
+        """
+        self.radius = radius
+        self._updateShadowPixmap()
+        self.update()
+
+    def setColor(self, color):
+        """Set the drop shadow color (`QColor`).
+        """
+        self.color = color
+        self._updateShadowPixmap()
+        self.update()
+
+    def setContentsMargins(self, *args, **kwargs):
+        QWidget.setContentsMargins(self, *args, **kwargs)
+        self._updateShadowPixmap()
+
+    def _updateShadowPixmap(self):
+        """Update the cached drop shadow pixmap.
+        """
+        # Rectangle casting the shadow
+        rect_size = QSize(*CACHED_SHADOW_RECT_SIZE)
+        left, top, right, bottom = self.getContentsMargins()
+        # Size of the pixmap.
+        pixmap_size = QSize(rect_size.width() + left + right,
+                            rect_size.height() + top + bottom)
+        shadow_rect = QRect(QPoint(left, top), rect_size)
+        pixmap = QPixmap(pixmap_size)
+        pixmap.fill(QColor(0, 0, 0, 0))
+        rect_fill_color = self.palette().color(QPalette.Window)
+
+        pixmap = render_drop_shadow_frame(pixmap, QRectF(shadow_rect),
+                                          shadow_color=self.color,
+                                          offset=self.offset,
+                                          radius=self.radius,
+                                          rect_fill_color=rect_fill_color)
+
+        self._shadowPixmap = pixmap
+
+    def paintEvent(self, event):
+        pixmap = self._shadowPixmap
+        widget_rect = QRectF(QPointF(0.0, 0.0), QSizeF(self.size()))
+        frame_rect = QRectF(self.contentsRect())
+        left, top, right, bottom = self.getContentsMargins()
+        pixmap_rect = QRectF(QPointF(0, 0), QSizeF(pixmap.size()))
+        # Shadow casting rectangle.
+        pixmap_shadow_rect = pixmap_rect.adjusted(left, top, -right, -bottom)
+        source_rects = self._shadowPixmapFragments(pixmap_rect,
+                                                   pixmap_shadow_rect)
+        target_rects = self._shadowPixmapFragments(widget_rect, frame_rect)
+        painter = QPainter(self)
+        for source, target in zip(source_rects, target_rects):
+            painter.drawPixmap(target, pixmap, source)
+        painter.end()
+
+    def _shadowPixmapFragments(self, pixmap_rect, shadow_rect):
+        """Return a list of 8 QRectF fragments for drawing a shadow.
+        """
+        s_left, s_top, s_right, s_bottom = \
+            shadow_rect.left(), shadow_rect.top(), \
+            shadow_rect.right(), shadow_rect.bottom()
+        s_width, s_height = shadow_rect.width(), shadow_rect.height()
+        p_width, p_height = pixmap_rect.width(), pixmap_rect.height()
+
+        top_left = QRectF(0.0, 0.0, s_left, s_top)
+        top = QRectF(s_left, 0.0, s_width, s_top)
+        top_right = QRectF(s_right, 0.0, p_width - s_width, s_top)
+        right = QRectF(s_right, s_top, p_width - s_right, s_height)
+        right_bottom = QRectF(shadow_rect.bottomRight(),
+                              pixmap_rect.bottomRight())
+        bottom = QRectF(shadow_rect.bottomLeft(),
+                        pixmap_rect.bottomRight() - \
+                        QPointF(p_width - s_right, 0.0))
+        bottom_left = QRectF(shadow_rect.bottomLeft() - QPointF(s_left, 0.0),
+                             pixmap_rect.bottomLeft() + QPointF(s_left, 0.0))
+        left = QRectF(pixmap_rect.topLeft() + QPointF(0.0, s_top),
+                      shadow_rect.bottomLeft())
+        return [top_left, top, top_right, right, right_bottom,
+                bottom, bottom_left, left]

Orange/OrangeCanvas/gui/stackedwidget.py

+"""
+=====================
+AnimatedStackedWidget
+=====================
+
+A widget similar to QStackedWidget that supports animated
+transitions between widgets.
+
+"""
+
+import logging
+
+from PyQt4.QtGui import QWidget, QFrame, QStackedLayout, QPixmap, \
+                        QPainter, QSizePolicy
+
+from PyQt4.QtCore import Qt, QPoint, QRect, QSize, QPropertyAnimation
+
+from PyQt4.QtCore import pyqtSignal as Signal
+from PyQt4.QtCore import pyqtProperty as Property
+
+from .utils import updates_disabled
+
+log = logging.getLogger(__name__)
+
+
+def clipMinMax(size, minSize, maxSize):
+    """Clip the size so it is bigger then minSize but smaller than maxSize.
+    """
+    return size.expandedTo(minSize).boundedTo(maxSize)
+
+
+def fixSizePolicy(size, hint, policy):
+    """Fix size so it conforms to the size policy and the given size hint.
+    """
+    width, height = hint.width(), hint.height()
+    expanding = policy.expandingDirections()
+    hpolicy, vpolicy = policy.horizontalPolicy(), policy.verticalPolicy()
+
+    if expanding & Qt.Horizontal:
+        width = max(width, size.width())
+
+    if hpolicy == QSizePolicy.Maximum:
+        width = min(width, size.width())
+
+    if expanding & Qt.Vertical:
+        height = max(height, size.height())
+
+    if vpolicy == QSizePolicy.Maximum:
+        height = min(height, hint.height())
+
+    return QSize(width, height).boundedTo(size)
+
+
+class StackLayout(QStackedLayout):
+    """A stacked layout with `sizeHint` always the same as that
+    of the current widget.
+
+    """
+    def __init__(self, parent=None):
+        QStackedLayout.__init__(self, parent)
+        self.currentChanged.connect(self._onCurrentChanged)
+
+    def sizeHint(self):
+        current = self.currentWidget()
+        if current:
+            hint = current.sizeHint()
+            # Clip the hint with min/max sizes.
+            hint = clipMinMax(hint, current.minimumSize(),
+                              current.maximumSize())
+            return hint
+        else:
+            return QStackedLayout.sizeHint(self)
+
+    def minimumSize(self):
+        current = self.currentWidget()
+        if current:
+            return current.minimumSize()
+        else:
+            return QStackedLayout.minimumSize(self)
+
+    def maximumSize(self):
+        current = self.currentWidget()
+        if current:
+            return current.maximumSize()
+        else:
+            return QStackedLayout.maximumSize(self)
+
+    def setGeometry(self, rect):
+        QStackedLayout.setGeometry(self, rect)
+        for i in range(self.count()):
+            w = self.widget(i)
+            hint = w.sizeHint()
+            geom = QRect(rect)
+            size = clipMinMax(rect.size(), w.minimumSize(), w.maximumSize())
+            size = fixSizePolicy(size, hint, w.sizePolicy())
+            geom.setSize(size)
+            if geom != w.geometry():
+                w.setGeometry(geom)
+
+    def _onCurrentChanged(self, index):
+        """Current widget changed, invalidate the layout.
+        """
+        self.invalidate()
+
+
+class AnimatedStackedWidget(QFrame):
+    currentChanged = Signal(int)
+    transitionStarted = Signal()
+    transitionFinished = Signal()
+
+    def __init__(self, parent=None, animationEnabled=True):
+        QFrame.__init__(self, parent)
+        self.__animationEnabled = animationEnabled
+
+        layout = StackLayout()
+
+        self.__fadeWidget = CrossFadePixmapWidget(self)
+
+        self.transitionAnimation = \
+            QPropertyAnimation(self.__fadeWidget, "blendingFactor_", self)
+        self.transitionAnimation.setStartValue(0.0)
+        self.transitionAnimation.setEndValue(1.0)
+        self.transitionAnimation.setDuration(100 if animationEnabled else 0)
+        self.transitionAnimation.finished.connect(
+            self.__onTransitionFinished
+        )
+
+        layout.addWidget(self.__fadeWidget)
+        layout.currentChanged.connect(self.__onLayoutCurrentChanged)
+
+        self.setLayout(layout)
+
+        self.__widgets = []
+        self.__currentIndex = -1
+        self.__nextCurrentIndex = -1
+
+    def setAnimationEnabled(self, animationEnabled):
+        """Enable/disable transition animations.
+        """
+        if self.__animationEnabled != animationEnabled:
+            self.__animationEnabled = animationEnabled
+            self.transitionAnimation.setDuration(
+                100 if animationEnabled else 0
+            )
+
+    def animationEnabled(self):
+        return self.__animationEnabled
+
+    def addWidget(self, widget):
+        """Add the widget to the stack in the last place.
+        """
+        return self.insertWidget(self.layout().count(), widget)
+
+    def insertWidget(self, index, widget):
+        """Insert widget at index.
+        """
+        index = min(index, self.count())
+        self.__widgets.insert(index, widget)
+        if index <= self.__currentIndex or self.__currentIndex == -1:
+            self.__currentIndex += 1
+        return self.layout().insertWidget(index, widget)
+
+    def removeWidget(self, widget):
+        """Remove `widget` from the stack.
+        """
+        index = self.__widgets.index(widget)
+        item = self.layout().takeAt(index)
+        assert(item.widget() is widget)
+        self.__widgets.pop(index)
+        widget.deleteLater()
+
+    def widget(self, index):
+        """Return the widget at `index`
+        """
+        return self.__widgets[index]
+
+    def indexOf(self, widget):
+        """Return the index of `widget` in the stack.
+        """
+        return self.__widgets.index(widget)
+
+    def count(self):
+        """Return the number of widgets in the stack.
+        """
+        return max(self.layout().count() - 1, 0)
+
+    def setCurrentWidget(self, widget):
+        """Set the current shown widget.
+        """
+        index = self.__widgets.index(widget)
+        self.setCurrentIndex(index)
+
+    def setCurrentIndex(self, index):
+        """Set the current shown widget index.
+        """
+        index = max(min(index, self.count() - 1), 0)
+        if self.__currentIndex == -1:
+            self.layout().setCurrentIndex(index)
+            self.__currentIndex = index
+            return
+
+#        if not self.animationEnabled():
+#            self.layout().setCurrentIndex(index)
+#            self.__currentIndex = index
+#            return
+
+        # else start the animation
+        current = self.__widgets[self.__currentIndex]
+        next_widget = self.__widgets[index]
+
+        current_pix = QPixmap.grabWidget(current)
+        next_pix = QPixmap.grabWidget(next_widget)
+
+        with updates_disabled(self):
+            self.__fadeWidget.setPixmap(current_pix)
+            self.__fadeWidget.setPixmap2(next_pix)
+            self.__nextCurrentIndex = index
+            self.__transitionStart()
+
+    def currentIndex(self):
+        return self.__currentIndex
+
+    def sizeHint(self):
+        hint = QFrame.sizeHint(self)
+        if hint.isEmpty():
+            hint = QSize(0, 0)
+        return hint
+
+    def __transitionStart(self):
+        """Start the transition.
+        """
+        log.debug("Stack transition start (%s)", str(self.objectName()))
+        # Set the fade widget as the current widget
+        self.__fadeWidget.blendingFactor_ = 0.0
+        self.layout().setCurrentWidget(self.__fadeWidget)
+        self.transitionAnimation.start()
+        self.transitionStarted.emit()
+
+    def __onTransitionFinished(self):
+        """Transition has finished.
+        """
+        log.debug("Stack transition finished (%s)" % str(self.objectName()))
+        self.__fadeWidget.blendingFactor_ = 1.0
+        self.__currentIndex = self.__nextCurrentIndex
+        with updates_disabled(self):
+            self.layout().setCurrentIndex(self.__currentIndex)
+        self.transitionFinished.emit()
+
+    def __onLayoutCurrentChanged(self, index):
+        # Suppress transitional __fadeWidget current widget
+        if index != self.count():
+            self.currentChanged.emit(index)
+
+
+class CrossFadePixmapWidget(QWidget):
+    """A widget for cross fading between two pixmaps.
+    """
+    def __init__(self, parent=None, pixmap1=None, pixmap2=None):
+        QWidget.__init__(self, parent)
+        self.setPixmap(pixmap1)
+        self.setPixmap2(pixmap2)
+        self.blendingFactor_ = 0.0
+        self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
+
+    def setPixmap(self, pixmap):
+        """Set pixmap 1
+        """
+        self.pixmap1 = pixmap
+        self.updateGeometry()
+
+    def setPixmap2(self, pixmap):
+        """Set pixmap 2
+        """
+        self.pixmap2 = pixmap
+        self.updateGeometry()
+
+    def setBlendingFactor(self, factor):
+        """Set the blending factor between the two pixmaps.
+        """
+        self.__blendingFactor = factor
+        self.updateGeometry()
+
+    def blendingFactor(self):
+        """Pixmap blending factor between 0.0 and 1.0
+        """
+        return self.__blendingFactor
+
+    blendingFactor_ = Property(float, fget=blendingFactor,
+                               fset=setBlendingFactor)
+
+    def sizeHint(self):
+        """Return an interpolated size between pixmap1.size()
+        and pixmap2.size()
+
+        """
+        if self.pixmap1 and self.pixmap2:
+            size1 = self.pixmap1.size()
+            size2 = self.pixmap2.size()
+            return size1 + self.blendingFactor_ * (size2 - size1)
+        else:
+            return QWidget.sizeHint(self)
+
+    def paintEvent(self, event):
+        """Paint the interpolated pixmap image.
+        """
+        p = QPainter(self)
+        p.setClipRect(event.rect())
+        factor = self.blendingFactor_ ** 2
+        if self.pixmap1 and 1. - factor:
+            p.setOpacity(1. - factor)
+            p.drawPixmap(QPoint(0, 0), self.pixmap1)
+        if self.pixmap2 and factor:
+            p.setOpacity(factor)
+            p.drawPixmap(QPoint(0, 0), self.pixmap2)

Orange/OrangeCanvas/gui/test.py

+"""
+Basic Qt testing framework
+==========================
+"""
+
+try:
+    import unittest2 as unittest
+except ImportError:
+    import unittest
+
+import gc
+
+from PyQt4.QtGui import QApplication
+from PyQt4.QtCore import QCoreApplication, QTimer
+
+
+class QAppTestCase(unittest.TestCase):
+    def setUp(self):
+        self.app = QApplication([])
+        QTimer.singleShot(20000, self.app.exit)
+
+    def tearDown(self):
+        if hasattr(self, "scene"):
+            self.scene.clear()
+            self.scene.deleteLater()
+            self.app.processEvents()
+            del self.scene
+        self.app.processEvents()
+        del self.app
+        gc.collect()
+
+    def singleShot(self, *args):
+        QTimer.singleShot(*args)
+
+
+class QCoreAppTestCase(unittest.TestCase):
+    def setUp(self):
+        self.app = QCoreApplication([])
+        QTimer.singleShot(20000, self.app.exit)
+
+    def tearDown(self):
+        del self.app
+        gc.collect()
+
+    def singleShot(self, *args):
+        QTimer.singleShot(*args)

Orange/OrangeCanvas/gui/tests/__init__.py

+"""
+Tests for gui toolkit
+
+"""

Orange/OrangeCanvas/gui/tests/test_dock.py

+"""
+Tests for the DockWidget.
+
+"""
+
+from PyQt4.QtGui import QWidget, QMainWindow, QListView, QTextEdit, \
+                        QToolButton, QStringListModel, QHBoxLayout, QLabel
+
+from PyQt4.QtCore import Qt
+
+from .. import test
+from ..dock import CollapsibleDockWidget
+
+
+class TestDock(test.QAppTestCase):
+    def test_dock_standalone(self):
+        widget = QWidget()
+        layout = QHBoxLayout()
+        widget.setLayout(layout)
+        layout.addStretch(1)
+        widget.show()
+
+        dock = CollapsibleDockWidget()
+        layout.addWidget(dock)
+        list_view = QListView()
+        list_view.setModel(QStringListModel(["a", "b"], list_view))
+
+        label = QLabel("A label. ")
+        label.setWordWrap(True)
+
+        dock.setExpandedWidget(label)
+        dock.setCollapsedWidget(list_view)
+        dock.setExpanded(True)
+
+        self.app.processEvents()
+
+        def toogle():
+#            print dock.width(), dock.minimumWidth(), dock.sizeHint()
+            dock.setExpanded(not dock.expanded())
+            self.singleShot(2000, toogle)
+
+        toogle()
+
+        self.app.exec_()
+
+    def test_dock_mainwinow(self):
+        mw = QMainWindow()
+        dock = CollapsibleDockWidget()
+        w1 = QTextEdit()
+
+        w2 = QToolButton()
+        w2.setFixedSize(38, 200)
+
+        dock.setExpandedWidget(w1)
+        dock.setCollapsedWidget(w2)
+
+        mw.addDockWidget(Qt.LeftDockWidgetArea, dock)
+        mw.setCentralWidget(QTextEdit())
+        mw.show()
+
+        def toogle():
+#            print dock.width(), dock.minimumWidth(), dock.sizeHint()
+            dock.setExpanded(not dock.expanded())
+            self.singleShot(2000, toogle)
+
+        toogle()
+
+        self.app.exec_()

Orange/OrangeCanvas/gui/tests/test_dropshadow.py

+"""
+Tests for DropShadowFrame wiget.
+
+"""
+
+from PyQt4.QtGui import (
+    QMainWindow, QWidget, QListView, QTextEdit, QHBoxLayout, QToolBar,
+    QVBoxLayout, QColor
+)
+
+from PyQt4.QtCore import Qt, QTimer
+from .. import dropshadow
+
+from .. import test
+
+
+class TestDropShadow(test.QAppTestCase):
+    def test_drop_shadow_old(self):
+        w = dropshadow._DropShadowWidget()
+        w.setContentsMargins(20, 20, 20, 20)
+        w.setLayout(QHBoxLayout())
+        w.layout().setContentsMargins(0, 0, 0, 0)
+        w.layout().addWidget(QListView())
+        w.show()
+        QTimer.singleShot(1500, lambda: w.setRadius(w.radius + 5))
+        self.app.exec_()
+
+    def test(self):
+        lv = QListView()
+        mw = QMainWindow()
+        # Add two tool bars, the shadow should extend over them.
+        mw.addToolBar(Qt.BottomToolBarArea, QToolBar())
+        mw.addToolBar(Qt.TopToolBarArea, QToolBar())
+        mw.setCentralWidget(lv)
+
+        f = dropshadow.DropShadowFrame(color=Qt.blue, radius=20)
+
+        f.setWidget(lv)
+
+        self.assertIs(f.parentWidget(), mw)
+        self.assertIs(f.widget(), lv)
+
+        mw.show()
+
+        self.app.processEvents()
+
+        self.singleShot(3000, lambda: f.setColor(Qt.red))
+        self.singleShot(4000, lambda: f.setRadius(30))
+        self.singleShot(5000, lambda: f.setRadius(40))
+        self.app.exec_()
+
+    def test1(self):
+        class FT(QToolBar):
+            def paintEvent(self, e):
+                pass
+
+        w = QMainWindow()
+        ftt, ftb = FT(), FT()
+        ftt.setFixedHeight(15)
+        ftb.setFixedHeight(15)
+
+        w.addToolBar(Qt.TopToolBarArea, ftt)
+        w.addToolBar(Qt.BottomToolBarArea, ftb)
+
+        f = dropshadow.DropShadowFrame()
+        te = QTextEdit()
+        c = QWidget()
+        c.setLayout(QVBoxLayout())
+        c.layout().setContentsMargins(20, 0, 20, 0)
+        c.layout().addWidget(te)
+        w.setCentralWidget(c)
+        f.setWidget(te)
+        f.radius = 15
+        f.color = QColor(Qt.blue)
+        w.show()
+
+        self.singleShot(3000, lambda: f.setColor(Qt.red))
+        self.singleShot(4000, lambda: f.setRadius(30))
+        self.singleShot(5000, lambda: f.setRadius(40))
+
+        self.app.exec_()
+
+
+if __name__ == "__main__":
+    test.unittest.main()

Orange/OrangeCanvas/gui/tests/test_stackedwidget.py

+"""
+Test for StackedWidget
+
+"""
+from PyQt4.QtGui import QWidget, QLabel, QGroupBox, QListView, QVBoxLayout
+
+from .. import test
+from .. import stackedwidget
+
+
+class TestStackedWidget(test.QAppTestCase):
+    def test(self):
+        window = QWidget()
+        layout = QVBoxLayout()
+        window.setLayout(layout)
+
+        stack = stackedwidget.AnimatedStackedWidget()
+        stack.transitionFinished.connect(self.app.exit)
+
+        layout.addStretch(2)
+        layout.addWidget(stack)
+        layout.addStretch(2)
+        window.show()
+
+        widget1 = QLabel("A label " * 10)
+        widget1.setWordWrap(True)
+
+        widget2 = QGroupBox("Group")
+
+        widget3 = QListView()
+        self.assertEqual(stack.count(), 0)
+        self.assertEqual(stack.currentIndex(), -1)
+
+        stack.addWidget(widget1)
+        self.assertEqual(stack.count(), 1)
+        self.assertEqual(stack.currentIndex(), 0)
+
+        stack.addWidget(widget2)
+        stack.addWidget(widget3)
+        self.assertEqual(stack.count(), 3)
+        self.assertEqual(stack.currentIndex(), 0)
+
+        def widgets():
+            return [stack.widget(i) for i in range(stack.count())]
+
+        self.assertSequenceEqual([widget1, widget2, widget3],
+                                 widgets())
+        stack.show()
+
+        stack.removeWidget(widget2)
+        self.assertEqual(stack.count(), 2)
+        self.assertEqual(stack.currentIndex(), 0)
+        self.assertSequenceEqual([widget1, widget3],
+                                 widgets())
+
+        stack.setCurrentIndex(1)
+        # wait until animation finished
+        self.app.exec_()
+
+        self.assertEqual(stack.currentIndex(), 1)
+
+        widget2 = QGroupBox("Group")
+        stack.insertWidget(1, widget2)
+        self.assertEqual(stack.count(), 3)
+        self.assertEqual(stack.currentIndex(), 2)
+        self.assertSequenceEqual([widget1, widget2, widget3],
+                                 widgets())
+
+        stack.transitionFinished.disconnect(self.app.exit)
+
+        self.singleShot(2000, lambda: stack.setCurrentIndex(0))
+        self.singleShot(4000, lambda: stack.setCurrentIndex(1))
+        self.singleShot(6000, lambda: stack.setCurrentIndex(2))
+
+        self.app.exec_()

Orange/OrangeCanvas/gui/tests/test_toolbar.py

+"""
+Test for DynamicResizeToolbar
+
+"""
+import logging
+
+from PyQt4.QtGui import QAction
+
+from PyQt4.QtCore import Qt
+
+from .. import test
+from .. import toolbar
+
+
+class ToolBoxTest(test.QAppTestCase):
+
+    def test_dynamic_toolbar(self):
+        logging.basicConfig(level=logging.DEBUG)
+        self.app.setStyleSheet("QToolButton { border: 1px solid red; }")
+
+        w = toolbar.DynamicResizeToolBar(None)
+
+        w.addAction(QAction("1", w))
+        w.addAction(QAction("2", w))
+        w.addAction(QAction("A long", w))
+        actions = list(w.actions())
+
+        w.resize(100, 30)
+        w.show()
+
+        w.raise_()
+
+        w.removeAction(actions[1])
+        w.insertAction(actions[0], actions[1])
+
+        self.assertSetEqual(set(actions), set(w.actions()))
+
+        self.singleShot(2000, lambda: w.setOrientation(Qt.Vertical))
+        self.singleShot(5000, lambda: w.removeAction(actions[1]))
+
+        self.app.exec_()

Orange/OrangeCanvas/gui/tests/test_toolbox.py

+"""
+Tests for ToolBox widget.
+
+"""
+
+from .. import test
+from .. import toolbox
+
+from PyQt4.QtGui import QLabel, QListView, QSpinBox, QIcon
+
+
+class TestToolBox(test.QAppTestCase):
+    def test_tool_box(self):
+        w = toolbox.ToolBox()
+        style = self.app.style()
+        icon = QIcon(style.standardPixmap(style.SP_FileIcon))
+        p1 = QLabel("A Label")
+        p2 = QListView()
+        p3 = QLabel("Another\nlabel")
+        p4 = QSpinBox()
+        w.addItem(p1, "T1", icon)
+        w.addItem(p2, "Tab " * 10, icon, "a tab")
+        w.addItem(p3, "t3")
+        w.addItem(p4, "t4")
+        w.show()
+        w.removeItem(2)
+#        w.insertItem(index, widget, text, icon, toolTip)
+
+        self.app.exec_()

Orange/OrangeCanvas/gui/tests/test_toolgrid.py

+from PyQt4.QtGui import QAction
+
+from .. import test
+from ..toolgrid import ToolGrid
+
+
+class TestToolGrid(test.QAppTestCase):
+    def test_tool_grid(self):
+        w = ToolGrid()
+        action_a = QAction("A", w)
+        action_b = QAction("B", w)
+        action_c = QAction("C", w)
+        action_d = QAction("D", w)
+        w.addAction(action_b)
+        w.insertAction(0, action_a)
+        w.addAction(action_c)
+        w.addAction(action_d)
+        w.removeAction(action_c)
+        w.removeAction(action_a)
+        w.insertAction(0, action_a)
+        w.setColumnCount(2)
+        w.insertAction(2, action_c)
+
+        triggered_actions = []
+
+        def p(action):
+            print action.text()
+
+        w.actionTriggered.connect(p)
+        w.actionTriggered.connect(triggered_actions.append)
+        action_a.trigger()
+
+        self.assertEqual(triggered_actions, [action_a])
+
+        w.show()
+        self.app.exec_()

Orange/OrangeCanvas/gui/toolbar.py

+"""
+A custom toolbar.
+
+"""
+from __future__ import division
+
+import logging
+
+from collections import namedtuple
+
+from PyQt4.QtGui import (
+    QWidget, QToolBar, QToolButton, QAction, QBoxLayout, QStyle, QStylePainter,
+    QStyleOptionToolBar, QSizePolicy
+)
+
+from PyQt4.QtCore import Qt, QSize, QPoint, QEvent, QSignalMapper
+
+from PyQt4.QtCore import pyqtSignal as Signal, \
+                         pyqtProperty as Property
+
+log = logging.getLogger(__name__)
+
+
+class DynamicResizeToolBar(QToolBar):
+    """A QToolBar subclass that dynamically resizes its toolbuttons
+    to fit available space (this is done by setting fixed size on the
+    button instances).
+
+    .. note:: the class does not support `QWidgetAction`s, separators, etc.
+
+    """
+
+    def __init__(self, parent=None, *args, **kwargs):
+        QToolBar.__init__(self, *args, **kwargs)
+
+#        if self.orientation() == Qt.Horizontal:
+#            self.setSizePolicy(QSizePolicy.Fixed,
+#                               QSizePolicy.MinimumExpanding)
+#        else:
+#            self.setSizePolicy(QSizePolicy.MinimumExpanding,
+#                               QSizePolicy.Fixed)
+
+    def resizeEvent(self, event):
+        QToolBar.resizeEvent(self, event)
+        size = event.size()
+        self.__layout(size)
+
+    def actionEvent(self, event):
+        QToolBar.actionEvent(self, event)
+        if event.type() == QEvent.ActionAdded or \
+                event.type() == QEvent.ActionRemoved:
+            self.__layout(self.size())
+
+    def sizeHint(self):
+        hint = QToolBar.sizeHint(self)
+        width, height = hint.width(), hint.height()
+        dx1, dy1, dw1, dh1 = self.getContentsMargins()
+        dx2, dy2, dw2, dh2 = self.layout().getContentsMargins()
+        dx, dy = dx1 + dx2, dy1 + dy2
+        dw, dh = dw1 + dw2, dh1 + dh2
+
+        count = len(self.actions())
+        spacing = self.layout().spacing()
+        space_spacing = max(count - 1, 0) * spacing
+
+        if self.orientation() == Qt.Horizontal:
+            width = int(height * 1.618) * count + space_spacing + dw + dx
+        else:
+            height = int(width * 1.618) * count + space_spacing + dh + dy
+        return QSize(width, height)
+
+    def __layout(self, size):
+        """Layout the buttons to fit inside size.
+        """
+        mygeom = self.geometry()
+        mygeom.setSize(size)
+
+        # Adjust for margins (both the widgets and the layouts.
+        dx, dy, dw, dh = self.getContentsMargins()
+        mygeom.adjust(dx, dy, -dw, -dh)
+
+        dx, dy, dw, dh = self.layout().getContentsMargins()
+        mygeom.adjust(dx, dy, -dw, -dh)
+
+        actions = self.actions()
+        widgets = map(self.widgetForAction, actions)
+
+        orientation = self.orientation()
+        if orientation == Qt.Horizontal:
+            widgets = sorted(widgets, key=lambda w: w.pos().x())
+        else:
+            widgets = sorted(widgets, key=lambda w: w.pos().y())
+
+        spacing = self.layout().spacing()
+        uniform_layout_helper(widgets, mygeom, orientation,
+                              spacing=spacing)
+
+
+def uniform_layout_helper(items, contents_rect, expanding, spacing):
+    """Set fixed sizes on 'items' so they can be lay out in
+    contents rect anf fil the whole space.
+
+    """
+    if len(items) == 0:
+        return
+
+    spacing_space = (len(items) - 1) * spacing
+
+    if expanding == Qt.Horizontal:
+        space = contents_rect.width() - spacing_space
+        setter = lambda w, s: w.setFixedWidth(s)
+    else:
+        space = contents_rect.height() - spacing_space
+        setter = lambda w, s: w.setFixedHeight(s)
+
+    base_size = space / len(items)
+    remainder = space % len(items)
+
+    for i, item in enumerate(items):
+        item_size = base_size + (1 if i < remainder else 0)
+        setter(item, item_size)
+
+
+########
+# Unused
+########
+
+_ToolBarSlot = namedtuple(
+    "_ToolBarAction",
+    ["index",
+     "action",
+     "button",
+     ]
+)
+
+
+class ToolBarButton(QToolButton):
+    def __init__(self, *args, **kwargs):
+        QToolButton.__init__(self, *args, **kwargs)
+
+
+class ToolBar(QWidget):
+
+    actionTriggered = Signal()
+    actionHovered = Signal()
+
+    def __init__(self, parent=None, toolButtonStyle=Qt.ToolButtonFollowStyle,
+                 orientation=Qt.Horizontal, iconSize=None, **kwargs):
+        QWidget.__init__(self, parent, **kwargs)
+
+        self.__actions = []
+        self.__toolButtonStyle = toolButtonStyle
+        self.__orientation = orientation
+
+        if iconSize is not None:
+            pm = self.style().pixelMetric(QStyle.PM_ToolBarIconSize)
+            iconSize = QSize(pm, pm)
+
+        self.__iconSize = iconSize
+
+        if orientation == Qt.Horizontal:
+            layout = QBoxLayout(QBoxLayout.LeftToRight)
+        else:
+            layout = QBoxLayout(QBoxLayout.TopToBottom)
+
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+        self.setLayout(layout)
+
+        self.__signalMapper = QSignalMapper()
+
+    def setToolButtonStyle(self, style):
+        if self.__toolButtonStyle != style:
+            for slot in self.__actions:
+                slot.button.setToolButtonStyle(style)
+            self.__toolButtonStyle = style
+
+    def toolButtonStyle(self):
+        return self.__toolButtonStyle
+
+    toolButtonStyle_ = Property(int, fget=toolButtonStyle,
+                                fset=setToolButtonStyle)
+
+    def setOrientation(self, orientation):
+        if self.__orientation != orientation:
+            if orientation == Qt.Horizontal:
+                self.layout().setDirection(QBoxLayout.LeftToRight)
+            else:
+                self.layout().setDirection(QBoxLayout.TopToBottom)
+            sp = self.sizePolicy()
+            sp.transpose()
+            self.setSizePolicy(sp)
+            self.__orientation = orientation
+
+    def orientation(self):
+        return self.__orientation
+
+    orientation_ = Property(int, fget=orientation, fset=setOrientation)
+
+    def setIconSize(self, size):
+        if self.__iconSize != size:
+            for slot in self.__actions:
+                slot.button.setIconSize(size)
+            self.__iconSize = size
+
+    def iconSize(self):
+        return self.__iconSize
+
+    iconSize_ = Property(QSize, fget=iconSize, fset=setIconSize)
+
+    def actionEvent(self, event):
+        action = event.action()
+        if event.type() == QEvent.ActionAdded:
+            if event.before() is not None:
+                index = self._indexForAction(event.before()) + 1
+            else:
+                index = self.count()
+
+            already_added = True
+            try:
+                self._indexForAction(action)
+            except IndexError:
+                already_added = False
+
+            if already_added:
+                log.error("Action ('%s') already inserted", action.text())
+                return
+
+            self.__insertAction(index, action)
+
+        elif event.type() == QEvent.ActionRemoved:
+            try:
+                index = self._indexForAction(event.action())
+            except IndexError:
+                log.error("Action ('%s') is not in the toolbar", action.text())
+                return
+
+            self.__removeAction(index)
+
+        elif event.type() == QEvent.ActionChanged:
+            pass
+
+        return QWidget.actionEvent(self, event)
+
+    def count(self):
+        return len(self.__actions)
+
+    def actionAt(self, point):
+        widget = self.childAt(QPoint)
+        if isinstance(widget, QToolButton):
+            return widget.defaultAction()
+
+    def _indexForAction(self, action):
+        for i, slot in enumerate(self.__actions):
+            if slot.action is action:
+                return i
+        raise IndexError("Action not in the toolbar")
+
+    def __insertAction(self, index, action):
+        """Insert action into index.
+        """
+        log.debug("Inserting action '%s' at %i.", action.text(), index)
+        button = ToolBarButton(self)
+        button.setDefaultAction(action)
+        button.setToolButtonStyle(self.toolButtonStyle_)
+        button.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
+        button.triggered[QAction].connect(self.actionTriggered)
+        self.__signalMapper.setMapping(button, action)
+        slot = _ToolBarSlot(index, action, button)
+        self.__actions.insert(index, slot)
+
+        for i in range(index + 1, len(self.__actions)):
+            self.__actions[i] = self.__actions[i]._replace(index=i)
+
+        self.layout().insertWidget(index, button, stretch=1)
+
+    def __removeAction(self, index):
+        """Remove action at index.
+        """
+        slot = self.__actions.pop(index)
+        log.debug("Removing action '%s'.", slot.action.text())
+        for i in range(index, len(self.__actions)):
+            self.__actions[i] = self.__actions[i]._replace(index=i)
+        self.layout().takeAt(index)
+        slot.button.hide()
+        slot.button.setParent(None)
+        slot.button.deleteLater()
+
+    def paintEvent(self, event):
+        try:
+            painter = QStylePainter(self)
+            opt = QStyleOptionToolBar()
+            opt.initFrom(self)
+
+            opt.features = QStyleOptionToolBar.None
+            opt.positionOfLine = QStyleOptionToolBar.OnlyOne
+            opt.positionWithinLine = QStyleOptionToolBar.OnlyOne
+
+            painter.drawControl(QStyle.CE_ToolBar, opt)
+            print self.style()
+        except Exception:
+            log.critical("Error", exc_info=1)
+        painter.end()

Orange/OrangeCanvas/gui/toolbox.py

+"""
+==============
+ToolBox Widget
+==============
+
+A reimplementation of the QToolBox widget but with all the tabs
+in a single QScrollArea and support for multiple open tabs.
+
+"""
+
+from collections import namedtuple
+from operator import eq, attrgetter
+
+from PyQt4.QtGui import (
+    QWidget, QFrame, QSizePolicy, QIcon, QFontMetrics, QPainter, QStyle,
+    QStyleOptionToolButton, QStyleOptionToolBoxV2, QPalette, QBrush, QPen,
+    QLinearGradient, QColor,
+    QScrollArea, QVBoxLayout, QToolButton,
+    QAction, QActionGroup
+)
+
+from PyQt4.QtCore import Qt, QSize, QRect, QPoint
+from PyQt4.QtCore import pyqtSignal as Signal, pyqtProperty as Property
+
+from .utils import brush_darker
+
+_ToolBoxPage = namedtuple(
+    "_ToolBoxPage",
+    ["index",
+     "widget",
+     "action",
+     "button"]
+    )
+
+
+FOCUS_OUTLINE_COLOR = "#609ED7"
+
+
+def create_tab_gradient(base_color):
+    """Create a default background gradient for a tab button from a single
+    color.
+
+    """
+    grad = QLinearGradient(0, 0, 0, 1)
+    grad.setStops([(0.0, base_color),
+                   (0.5, base_color),
+                   (0.8, base_color.darker(105)),
+                   (1.0, base_color.darker(110)),
+                   ])
+    grad.setCoordinateMode(QLinearGradient.ObjectBoundingMode)
+    return grad
+
+
+class ToolBoxTabButton(QToolButton):
+    """A tab button for an item in a ToolBox.
+    """
+
+    def setNativeStyling(self, state):
+        """Render tab buttons as native QToolButtons.
+        """
+        self.__nativeStyling = state
+        self.update()
+
+    def nativeStyling(self):
+        """Use native QStyle's QToolButton look.
+        """
+        return self.__nativeStyling
+
+    nativeStyling_ = Property(bool,
+                              fget=nativeStyling,
+                              fset=setNativeStyling,
+                              designable=True)
+
+    def __init__(self, *args, **kwargs):
+        self.__nativeStyling = False
+        self.position = QStyleOptionToolBoxV2.OnlyOneTab
+        self.selected = QStyleOptionToolBoxV2.NotAdjacent
+
+        QToolButton.__init__(self, *args, **kwargs)
+
+    def paintEvent(self, event):
+        if self.__nativeStyling:
+            QToolButton.paintEvent(self, event)
+        else:
+            self.__paintEventNoStyle()
+
+    def __paintEventNoStyle(self):
+        p = QPainter(self)
+        opt = QStyleOptionToolButton()
+        self.initStyleOption(opt)
+
+        fm = QFontMetrics(opt.font)
+        palette = opt.palette
+
+        # highlight brush is used as the background for the icon and background
+        # when the tab is expanded and as mouse hover color (lighter).
+        brush_highlight = palette.highlight()
+        if opt.state & QStyle.State_Sunken:
+            # State 'down' pressed during a mouse press (slightly darker).
+            background_brush = brush_darker(brush_highlight, 110)
+        elif opt.state & QStyle.State_MouseOver:
+            background_brush = brush_darker(brush_highlight, 95)
+        elif opt.state & QStyle.State_On:
+            background_brush = brush_highlight
+        else:
+            # The default button brush.
+            background_brush = palette.button()
+
+        rect = opt.rect
+        icon = opt.icon
+        icon_size = opt.iconSize
+
+        # TODO: add shift for pressed as set by the style (PM_ButtonShift...)
+
+        pm = None
+        if not icon.isNull():
+            if opt.state & QStyle.State_Enabled:
+                mode = QIcon.Normal
+            else:
+                mode = QIcon.Disabled
+
+            pm = opt.icon.pixmap(
+                    rect.size().boundedTo(icon_size), mode,
+                    QIcon.On if opt.state & QStyle.State_On else QIcon.Off)
+
+        icon_area_rect = QRect(rect)
+        icon_area_rect.setRight(int(icon_area_rect.height() * 1.26))
+
+        text_rect = QRect(rect)
+        text_rect.setLeft(icon_area_rect.right() + 10)
+
+        # Background  (TODO: Should the tab button have native
+        # toolbutton shape, drawn using PE_PanelButtonTool or even
+        # QToolBox tab shape)
+
+        # Default outline pen
+        pen = QPen(palette.color(QPalette.Mid))
+
+        p.save()
+        p.setPen(Qt.NoPen)
+        p.setBrush(QBrush(background_brush))
+        p.drawRect(rect)
+
+        # Draw the background behind the icon if the background_brush
+        # is different.
+        if not opt.state & QStyle.State_On:
+            p.setBrush(brush_highlight)
+            p.drawRect(icon_area_rect)
+            # Line between the icon and text
+            p.setPen(pen)
+            p.drawLine(icon_area_rect.topRight(),
+                       icon_area_rect.bottomRight())
+
+        if opt.state & QStyle.State_HasFocus:
+            # Set the focus frame pen and draw the border
+            pen = QPen(QColor(FOCUS_OUTLINE_COLOR))
+            p.setPen(pen)
+            p.setBrush(Qt.NoBrush)
+            # Adjust for pen
+            rect = rect.adjusted(0, 0, -1, -1)
+            p.drawRect(rect)
+
+        else:
+            p.setPen(pen)
+            # Draw the top/bottom border
+            if self.position == QStyleOptionToolBoxV2.OnlyOneTab or \
+                    self.position == QStyleOptionToolBoxV2.Beginning or \
+                    self.selected & \
+                        QStyleOptionToolBoxV2.PreviousIsSelected:
+
+                p.drawLine(rect.topLeft(), rect.topRight())
+
+            p.drawLine(rect.bottomLeft(), rect.bottomRight())
+
+        p.restore()
+
+        p.save()
+        text = fm.elidedText(opt.text, Qt.ElideRight, text_rect.width())
+        p.setPen(QPen(palette.color(QPalette.ButtonText)))
+        p.setFont(opt.font)
+
+        p.drawText(text_rect,
+                   int(Qt.AlignVCenter | Qt.AlignLeft) | \
+                   int(Qt.TextSingleLine),
+                   text)
+        if pm:
+            pm_rect = QRect(QPoint(0, 0), pm.size())
+            centered_rect = QRect(pm_rect)
+            centered_rect.moveCenter(icon_area_rect.center())
+            p.drawPixmap(centered_rect, pm, pm_rect)
+        p.restore()
+
+
+class ToolBox(QFrame):
+    """A tool box widget.
+    """
+    tabToogled = Signal(int, bool)
+
+    def setExclusive(self, exclusive):
+        """Set exclusive tabs (only one tab can be open at a time).
+        """
+        self.__exclusive = exclusive
+
+    def exclusive(self):
+        return self.__exclusive
+
+    exclusive_ = Property(bool,
+                         fget=exclusive,
+                         fset=setExclusive,
+                         designable=True)
+
+    def __init__(self, parent=None, **kwargs):
+        QFrame.__init__(self, parent, **kwargs)
+
+        self.__pages = []
+        self.__tabButtonHeight = -1
+        self.__tabIconSize = QSize()
+        self.__exclusive = False
+        self.__setupUi()
+
+    def __setupUi(self):
+        layout = QVBoxLayout()
+        layout.setContentsMargins(0, 0, 0, 0)
+
+        # Scroll area for the contents.
+        self.__scrollArea = \
+                QScrollArea(self, objectName="toolbox-scroll-area")
+
+        self.__scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
+        self.__scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+        self.__scrollArea.setSizePolicy(QSizePolicy.MinimumExpanding,
+                                       QSizePolicy.MinimumExpanding)
+        self.__scrollArea.setFrameStyle(QScrollArea.NoFrame)
+
+        # A widget with all of the contents.
+        # The tabs/contents are placed in the layout inside this widget
+        self.__contents = QWidget(self.__scrollArea,
+                                  objectName="toolbox-contents")
+
+        # The layout where all the tab/pages are placed
+        self.__contentsLayout = QVBoxLayout()
+        self.__contentsLayout.setContentsMargins(0, 0, 0, 0)
+        self.__contentsLayout.setSizeConstraint(QVBoxLayout.SetMinAndMaxSize)
+        self.__contentsLayout.setSpacing(0)
+
+        self.__contents.setLayout(self.__contentsLayout)
+
+        self.__scrollArea.setWidget(self.__contents)
+
+        layout.addWidget(self.__scrollArea)
+        self.setLayout(layout)
+        self.setSizePolicy(QSizePolicy.Fixed,
+                           QSizePolicy.MinimumExpanding)
+
+        self.__tabActionGroup = \
+                QActionGroup(self, objectName="toolbox-tab-action-group")
+
+        self.__tabActionGroup.setExclusive(self.exclusive())
+        self.__tabActionGroup.triggered.connect(self.__onTabActionTriggered)
+
+    def setTabButtonHeight(self, height):
+        """Set the tab button height.
+        """
+        if self.__tabButtonHeight != height:
+            self.__tabButtonHeight = height
+            for page in self.__pages:
+                page.button.setFixedHeight(height)
+
+    def tabButtonHeight(self):
+        return self.__tabButtonHeight
+
+    def setTabIconSize(self, size):
+        """Set the tab button icon size.
+        """
+        if self.__tabIconSize != size:
+            self.__tabIconSize = size
+            for page in self.__pages:
+                page.button.setIconSize(size)
+
+    def tabIconSize(self):
+        return self.__tabIconSize
+
+    def tabButton(self, i):
+        """Return the tab button for the `i`-th item.
+        """
+        return self.__pages[i].button
+
+    def tabAction(self, i):
+        """Return open/close action for the `i`-th tab.
+        """
+        return self.__pages[i].action
+
+    def addItem(self, widget, text, icon=None, toolTip=None):
+        """Add the `widget` in a new tab. Return the index of the new tab.
+        """
+        return self.insertItem(self.count(), widget, text, icon, toolTip)
+
+    def insertItem(self, index, widget, text, icon=None, toolTip=None):
+        """Insert the `widget` in a new tab at position `index`.
+        """
+        button = self.createTabButton(widget, text, icon, toolTip)
+
+        self.__contentsLayout.insertWidget(index * 2, button)
+        self.__contentsLayout.insertWidget(index * 2 + 1, widget)
+
+        widget.hide()
+
+        page = _ToolBoxPage(index, widget, button.defaultAction(), button)
+        self.__pages.insert(index, page)
+
+        for i in range(index + 1, self.count()):
+            self.__pages[i] = self.__pages[i]._replace(index=i)
+
+        self.__updatePositions()
+
+        # Show (open) the first tab.
+        if self.count() == 1 and index == 0:
+            page.action.trigger()
+
+        self.__updateSelected()
+
+        self.updateGeometry()
+        return index
+
+    def removeItem(self, index):
+        self.__contentsLayout.takeAt(2 * index + 1)
+        self.__contentsLayout.takeAt(2 * index)
+        page = self.__pages.pop(index)
+
+        for i in range(index, self.count()):
+            self.__pages[i] = self.__pages[i]._replace(index=i)
+
+        page.button.deleteLater()
+        page.widget.deleteLater()
+
+        self.__updatePositions()
+        self.__updateSelected()
+
+        self.updateGeometry()
+
+    def count(self):
+        return len(self.__pages)
+
+    def widget(self, index):
+        """Return the widget at index.
+        """
+        self.__pages[index].widget
+
+    def createTabButton(self, widget, text, icon=None, toolTip=None):
+        """Create the tab button for `widget`.
+        """
+        action = QAction(text, self)
+        action.setCheckable(True)
+
+        if icon:
+            action.setIcon(icon)
+
+        if toolTip:
+            action.setToolTip(toolTip)
+        self.__tabActionGroup.addAction(action)
+
+        button = ToolBoxTabButton(self, objectName="toolbox-tab-button")
+        button.setDefaultAction(action)
+        button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
+        button.setSizePolicy(QSizePolicy.Expanding,
+                             QSizePolicy.Fixed)
+
+        if self.__tabIconSize.isValid():
+            button.setIconSize(self.__tabIconSize)
+
+        if self.__tabButtonHeight > 0:
+            button.setFixedHeight(self.__tabButtonHeight)
+
+        return button
+
+    def ensureWidgetVisible(self, child, xmargin=50, ymargin=50):
+        """Scroll the contents so child widget instance is visible inside
+        the viewport.