source: orange/Orange/OrangeCanvas/gui/toolbox.py @ 11441:4af8424bfa58

Revision 11441:4af8424bfa58, 18.3 KB checked in by Ales Erjavec <ales.erjavec@…>, 13 months ago (diff)

Fixed 'ToolBox.exclusive' property update.

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