source: orange/Orange/OrangeCanvas/gui/toolbox.py @ 11508:5f54d6c68d9e

Revision 11508:5f54d6c68d9e, 17.8 KB checked in by Ales Erjavec <ales.erjavec@…>, 12 months ago (diff)

Moved gradient functions into gui.utils.

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