source: orange/Orange/OrangeCanvas/gui/toolbox.py @ 11365:370f24214156

Revision 11365:370f24214156, 16.4 KB checked in by Ales Erjavec <ales.erjavec@…>, 14 months ago (diff)

Changed ToolBox.removeItem and StackedWidget.removeWidget to not delete the widget.

This follows standard Qt4's semantics.

Line 
1"""
2==============
3ToolBox Widget
4==============
5
6A reimplementation of the QToolBox widget but with all the tabs
7in a single QScrollArea and support for multiple open tabs.
8
9"""
10
11from collections import namedtuple
12from operator import eq, attrgetter
13
14from PyQt4.QtGui import (
15    QWidget, QFrame, QSizePolicy, QIcon, QFontMetrics, QPainter, QStyle,
16    QStyleOptionToolButton, QStyleOptionToolBoxV2, QPalette, QBrush, QPen,
17    QLinearGradient, QColor, QScrollArea, QVBoxLayout, QToolButton,
18    QAction, QActionGroup
19)
20
21from PyQt4.QtCore import (
22    Qt, QObject, QSize, QRect, QPoint, QSignalMapper, QEvent
23)
24
25from PyQt4.QtCore import pyqtSignal as Signal, pyqtProperty as Property
26
27from .utils import brush_darker
28
29_ToolBoxPage = namedtuple(
30    "_ToolBoxPage",
31    ["index",
32     "widget",
33     "action",
34     "button"]
35    )
36
37
38FOCUS_OUTLINE_COLOR = "#609ED7"
39
40
41def create_tab_gradient(base_color):
42    """Create a default background gradient for a tab button from a single
43    color.
44
45    """
46    grad = QLinearGradient(0, 0, 0, 1)
47    grad.setStops([(0.0, base_color),
48                   (0.5, base_color),
49                   (0.8, base_color.darker(105)),
50                   (1.0, base_color.darker(110)),
51                   ])
52    grad.setCoordinateMode(QLinearGradient.ObjectBoundingMode)
53    return grad
54
55
56class ToolBoxTabButton(QToolButton):
57    """A tab button for an item in a ToolBox.
58    """
59
60    def setNativeStyling(self, state):
61        """Render tab buttons as native QToolButtons.
62        """
63        self.__nativeStyling = state
64        self.update()
65
66    def nativeStyling(self):
67        """Use native QStyle's QToolButton look.
68        """
69        return self.__nativeStyling
70
71    nativeStyling_ = Property(bool,
72                              fget=nativeStyling,
73                              fset=setNativeStyling,
74                              designable=True)
75
76    def __init__(self, *args, **kwargs):
77        self.__nativeStyling = False
78        self.position = QStyleOptionToolBoxV2.OnlyOneTab
79        self.selected = QStyleOptionToolBoxV2.NotAdjacent
80
81        QToolButton.__init__(self, *args, **kwargs)
82
83    def paintEvent(self, event):
84        if self.__nativeStyling:
85            QToolButton.paintEvent(self, event)
86        else:
87            self.__paintEventNoStyle()
88
89    def __paintEventNoStyle(self):
90        p = QPainter(self)
91        opt = QStyleOptionToolButton()
92        self.initStyleOption(opt)
93
94        fm = QFontMetrics(opt.font)
95        palette = opt.palette
96
97        # highlight brush is used as the background for the icon and background
98        # when the tab is expanded and as mouse hover color (lighter).
99        brush_highlight = palette.highlight()
100        if opt.state & QStyle.State_Sunken:
101            # State 'down' pressed during a mouse press (slightly darker).
102            background_brush = brush_darker(brush_highlight, 110)
103        elif opt.state & QStyle.State_MouseOver:
104            background_brush = brush_darker(brush_highlight, 95)
105        elif opt.state & QStyle.State_On:
106            background_brush = brush_highlight
107        else:
108            # The default button brush.
109            background_brush = palette.button()
110
111        rect = opt.rect
112        icon = opt.icon
113        icon_size = opt.iconSize
114
115        # TODO: add shift for pressed as set by the style (PM_ButtonShift...)
116
117        pm = None
118        if not icon.isNull():
119            if opt.state & QStyle.State_Enabled:
120                mode = QIcon.Normal
121            else:
122                mode = QIcon.Disabled
123
124            pm = opt.icon.pixmap(
125                    rect.size().boundedTo(icon_size), mode,
126                    QIcon.On if opt.state & QStyle.State_On else QIcon.Off)
127
128        icon_area_rect = QRect(rect)
129        icon_area_rect.setRight(int(icon_area_rect.height() * 1.26))
130
131        text_rect = QRect(rect)
132        text_rect.setLeft(icon_area_rect.right() + 10)
133
134        # Background  (TODO: Should the tab button have native
135        # toolbutton shape, drawn using PE_PanelButtonTool or even
136        # QToolBox tab shape)
137
138        # Default outline pen
139        pen = QPen(palette.color(QPalette.Mid))
140
141        p.save()
142        p.setPen(Qt.NoPen)
143        p.setBrush(QBrush(background_brush))
144        p.drawRect(rect)
145
146        # Draw the background behind the icon if the background_brush
147        # is different.
148        if not opt.state & QStyle.State_On:
149            p.setBrush(brush_highlight)
150            p.drawRect(icon_area_rect)
151            # Line between the icon and text
152            p.setPen(pen)
153            p.drawLine(icon_area_rect.topRight(),
154                       icon_area_rect.bottomRight())
155
156        if opt.state & QStyle.State_HasFocus:
157            # Set the focus frame pen and draw the border
158            pen = QPen(QColor(FOCUS_OUTLINE_COLOR))
159            p.setPen(pen)
160            p.setBrush(Qt.NoBrush)
161            # Adjust for pen
162            rect = rect.adjusted(0, 0, -1, -1)
163            p.drawRect(rect)
164
165        else:
166            p.setPen(pen)
167            # Draw the top/bottom border
168            if self.position == QStyleOptionToolBoxV2.OnlyOneTab or \
169                    self.position == QStyleOptionToolBoxV2.Beginning or \
170                    self.selected & \
171                        QStyleOptionToolBoxV2.PreviousIsSelected:
172
173                p.drawLine(rect.topLeft(), rect.topRight())
174
175            p.drawLine(rect.bottomLeft(), rect.bottomRight())
176
177        p.restore()
178
179        p.save()
180        text = fm.elidedText(opt.text, Qt.ElideRight, text_rect.width())
181        p.setPen(QPen(palette.color(QPalette.ButtonText)))
182        p.setFont(opt.font)
183
184        p.drawText(text_rect,
185                   int(Qt.AlignVCenter | Qt.AlignLeft) | \
186                   int(Qt.TextSingleLine),
187                   text)
188        if pm:
189            pm_rect = QRect(QPoint(0, 0), pm.size())
190            centered_rect = QRect(pm_rect)
191            centered_rect.moveCenter(icon_area_rect.center())
192            p.drawPixmap(centered_rect, pm, pm_rect)
193        p.restore()
194
195
196class _ToolBoxScrollArea(QScrollArea):
197    def eventFilter(self, obj, event):
198        if obj is self.widget() and event.type() == QEvent.Resize:
199            if event.size() == event.oldSize() and self.widgetResizable():
200                # This is driving me insane. This should not have happened.
201                # Before the event is sent QWidget specifically makes sure the
202                # sizes are different, but somehow I still get this, and enter
203                # an infinite recursion if I enter QScrollArea.eventFilter.
204                # I can only duplicate this on one development machine a
205                # Mac OSX using fink and Qt 4.7.3
206                return False
207
208        return QScrollArea.eventFilter(self, obj, event)
209
210
211class ToolBox(QFrame):
212    """A tool box widget.
213    """
214    tabToogled = Signal(int, bool)
215
216    def setExclusive(self, exclusive):
217        """Set exclusive tabs (only one tab can be open at a time).
218        """
219        if self.__exclusive != exclusive:
220            self.__exclusive = exclusive
221            self.__tabActionGroup.setExclusive(exclusive)
222
223    def exclusive(self):
224        return self.__exclusive
225
226    exclusive_ = Property(bool,
227                         fget=exclusive,
228                         fset=setExclusive,
229                         designable=True)
230
231    def __init__(self, parent=None, **kwargs):
232        QFrame.__init__(self, parent, **kwargs)
233
234        self.__pages = []
235        self.__tabButtonHeight = -1
236        self.__tabIconSize = QSize()
237        self.__exclusive = False
238        self.__setupUi()
239
240    def __setupUi(self):
241        layout = QVBoxLayout()
242        layout.setContentsMargins(0, 0, 0, 0)
243
244        # Scroll area for the contents.
245        self.__scrollArea = \
246                _ToolBoxScrollArea(self, objectName="toolbox-scroll-area")
247
248        self.__scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
249        self.__scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
250        self.__scrollArea.setSizePolicy(QSizePolicy.MinimumExpanding,
251                                        QSizePolicy.MinimumExpanding)
252        self.__scrollArea.setFrameStyle(QScrollArea.NoFrame)
253        self.__scrollArea.setWidgetResizable(True)
254
255        # A widget with all of the contents.
256        # The tabs/contents are placed in the layout inside this widget
257        self.__contents = QWidget(self.__scrollArea,
258                                  objectName="toolbox-contents")
259
260        # The layout where all the tab/pages are placed
261        self.__contentsLayout = QVBoxLayout()
262        self.__contentsLayout.setContentsMargins(0, 0, 0, 0)
263        self.__contentsLayout.setSizeConstraint(QVBoxLayout.SetMinAndMaxSize)
264        self.__contentsLayout.setSpacing(0)
265
266        self.__contents.setLayout(self.__contentsLayout)
267
268        self.__scrollArea.setWidget(self.__contents)
269
270        layout.addWidget(self.__scrollArea)
271
272        self.setLayout(layout)
273        self.setSizePolicy(QSizePolicy.Fixed,
274                           QSizePolicy.MinimumExpanding)
275
276        self.__tabActionGroup = \
277                QActionGroup(self, objectName="toolbox-tab-action-group")
278
279        self.__tabActionGroup.setExclusive(self.__exclusive)
280
281        self.__actionMapper = QSignalMapper(self)
282        self.__actionMapper.mapped[QObject].connect(self.__onTabActionToogled)
283
284    def setTabButtonHeight(self, height):
285        """Set the tab button height.
286        """
287        if self.__tabButtonHeight != height:
288            self.__tabButtonHeight = height
289            for page in self.__pages:
290                page.button.setFixedHeight(height)
291
292    def tabButtonHeight(self):
293        return self.__tabButtonHeight
294
295    def setTabIconSize(self, size):
296        """Set the tab button icon size.
297        """
298        if self.__tabIconSize != size:
299            self.__tabIconSize = size
300            for page in self.__pages:
301                page.button.setIconSize(size)
302
303    def tabIconSize(self):
304        return self.__tabIconSize
305
306    def tabButton(self, i):
307        """Return the tab button for the `i`-th item.
308        """
309        return self.__pages[i].button
310
311    def tabAction(self, i):
312        """Return open/close action for the `i`-th tab.
313        """
314        return self.__pages[i].action
315
316    def addItem(self, widget, text, icon=None, toolTip=None):
317        """Add the `widget` in a new tab. Return the index of the new tab.
318        """
319        return self.insertItem(self.count(), widget, text, icon, toolTip)
320
321    def insertItem(self, index, widget, text, icon=None, toolTip=None):
322        """Insert the `widget` in a new tab at position `index`.
323        """
324        button = self.createTabButton(widget, text, icon, toolTip)
325
326        self.__contentsLayout.insertWidget(index * 2, button)
327        self.__contentsLayout.insertWidget(index * 2 + 1, widget)
328
329        widget.hide()
330
331        page = _ToolBoxPage(index, widget, button.defaultAction(), button)
332        self.__pages.insert(index, page)
333
334        for i in range(index + 1, self.count()):
335            self.__pages[i] = self.__pages[i]._replace(index=i)
336
337        self.__updatePositions()
338
339        # Show (open) the first tab.
340        if self.count() == 1 and index == 0:
341            page.action.trigger()
342
343        self.__updateSelected()
344
345        self.updateGeometry()
346        return index
347
348    def removeItem(self, index):
349        self.__contentsLayout.takeAt(2 * index + 1)
350        self.__contentsLayout.takeAt(2 * index)
351        page = self.__pages.pop(index)
352
353        for i in range(index, self.count()):
354            self.__pages[i] = self.__pages[i]._replace(index=i)
355
356        page.button.deleteLater()
357
358        # Hide the widget and reparent to self
359        # This follows QToolBox.removeItem
360        page.widget.hide()
361        page.widget.setParent(self)
362
363        self.__updatePositions()
364        self.__updateSelected()
365
366        self.updateGeometry()
367
368    def count(self):
369        return len(self.__pages)
370
371    def widget(self, index):
372        """Return the widget at index.
373        """
374        return self.__pages[index].widget
375
376    def createTabButton(self, widget, text, icon=None, toolTip=None):
377        """Create the tab button for `widget`.
378        """
379        action = QAction(text, self)
380        action.setCheckable(True)
381
382        if icon:
383            action.setIcon(icon)
384
385        if toolTip:
386            action.setToolTip(toolTip)
387        self.__tabActionGroup.addAction(action)
388        self.__actionMapper.setMapping(action, action)
389        action.toggled.connect(self.__actionMapper.map)
390
391        button = ToolBoxTabButton(self, objectName="toolbox-tab-button")
392        button.setDefaultAction(action)
393        button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
394        button.setSizePolicy(QSizePolicy.Expanding,
395                             QSizePolicy.Fixed)
396
397        if self.__tabIconSize.isValid():
398            button.setIconSize(self.__tabIconSize)
399
400        if self.__tabButtonHeight > 0:
401            button.setFixedHeight(self.__tabButtonHeight)
402
403        return button
404
405    def ensureWidgetVisible(self, child, xmargin=50, ymargin=50):
406        """Scroll the contents so child widget instance is visible inside
407        the viewport.
408
409        """
410        self.__scrollArea.ensureWidgetVisible(child, xmargin, ymargin)
411
412    def sizeHint(self):
413        hint = self.__contentsLayout.sizeHint()
414
415        if self.count():
416            # Compute max width of hidden widgets also.
417            scroll = self.__scrollArea
418            scroll_w = scroll.verticalScrollBar().sizeHint().width()
419            frame_w = self.frameWidth() * 2 + scroll.frameWidth() * 2
420            max_w = max([p.widget.sizeHint().width() for p in self.__pages])
421            hint = QSize(max(max_w, hint.width()) + scroll_w + frame_w,
422                         hint.height())
423
424        return QSize(200, 200).expandedTo(hint)
425
426    def __onTabActionToogled(self, action):
427        page = find(self.__pages, action, key=attrgetter("action"))
428        on = action.isChecked()
429        page.widget.setVisible(on)
430        index = page.index
431
432        if index > 0:
433            # Update the `previous` tab buttons style hints
434            previous = self.__pages[index - 1].button
435            flag = QStyleOptionToolBoxV2.NextIsSelected
436            if on:
437                previous.selected |= flag
438            else:
439                previous.selected &= ~flag
440
441            previous.update()
442
443        if index < self.count() - 1:
444            next = self.__pages[index + 1].button
445            flag = QStyleOptionToolBoxV2.PreviousIsSelected
446            if on:
447                next.selected |= flag
448            else:
449                next.selected &= ~flag
450
451            next.update()
452
453        self.tabToogled.emit(index, on)
454
455        self.__contentsLayout.invalidate()
456
457    def __updateSelected(self):
458        """Update the tab buttons selected style flags.
459        """
460        if self.count() == 0:
461            return
462
463        opt = QStyleOptionToolBoxV2
464
465        def update(button, next_sel, prev_sel):
466            if next_sel:
467                button.selected |= opt.NextIsSelected
468            else:
469                button.selected &= ~opt.NextIsSelected
470
471            if prev_sel:
472                button.selected |= opt.PreviousIsSelected
473            else:
474                button.selected &= ~ opt.PreviousIsSelected
475
476            button.update()
477
478        if self.count() == 1:
479            update(self.__pages[0].button, False, False)
480        elif self.count() >= 2:
481            pages = self.__pages
482            for i in range(1, self.count() - 1):
483                update(pages[i].button,
484                       pages[i + 1].action.isChecked(),
485                       pages[i - 1].action.isChecked())
486
487    def __updatePositions(self):
488        """Update the tab buttons position style flags.
489        """
490        if self.count() == 0:
491            return
492        elif self.count() == 1:
493            self.__pages[0].button.position = QStyleOptionToolBoxV2.OnlyOneTab
494        else:
495            self.__pages[0].button.position = QStyleOptionToolBoxV2.Beginning
496            self.__pages[-1].button.position = QStyleOptionToolBoxV2.End
497            for p in self.__pages[1:-1]:
498                p.button.position = QStyleOptionToolBoxV2.Middle
499
500        for p in self.__pages:
501            p.button.update()
502
503
504def identity(arg):
505    return arg
506
507
508def find(iterable, *what, **kwargs):
509    """find(iterable, [what, [key=None, [predicate=operator.eq]]])
510    """
511    if what:
512        what = what[0]
513    key, predicate = kwargs.get("key", identity), kwargs.get("predicate", eq)
514    for item in iterable:
515        item_key = key(item)
516        if predicate(item_key, what):
517            return item
518    else:
519        raise ValueError(what)
Note: See TracBrowser for help on using the repository browser.