Source code for orangecanvas.gui.toolbox
"""
===============
Tool Box Widget
===============
A reimplementation of the :class:`QToolBox` widget that keeps all the tabs
in a single :class:`QScrollArea` instance and can keep multiple open tabs.
"""
from collections import namedtuple
from operator import eq, attrgetter
from AnyQt.QtWidgets import (
QWidget, QFrame, QSizePolicy, QStyle, QStyleOptionToolButton,
QStyleOptionToolBox, QScrollArea, QVBoxLayout, QToolButton,
QAction, QActionGroup, QApplication, QWIDGETSIZE_MAX
)
from AnyQt.QtGui import (
QIcon, QFontMetrics, QPainter, QPalette, QBrush, QPen, QColor,
)
from AnyQt.QtCore import (
Qt, QObject, QSize, QRect, QPoint, QSignalMapper
)
from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property
from .utils import brush_darker
_ToolBoxPage = namedtuple(
"_ToolBoxPage",
["index",
"widget",
"action",
"button"]
)
FOCUS_OUTLINE_COLOR = "#609ED7"
class ToolBoxTabButton(QToolButton):
"""
A tab button for an item in a :class:`ToolBox`.
"""
def setNativeStyling(self, state):
"""
Render tab buttons as native (or css styled) :class:`QToolButtons`.
If set to `False` (default) the button is pained using a custom
paint routine.
"""
self.__nativeStyling = state
self.update()
def nativeStyling(self):
"""
Use :class:`QStyle`'s to paint the class:`QToolButton` look.
"""
return self.__nativeStyling
nativeStyling_ = Property(bool,
fget=nativeStyling,
fset=setNativeStyling,
designable=True)
def __init__(self, *args, **kwargs):
self.__nativeStyling = False
self.position = QStyleOptionToolBox.OnlyOneTab
self.selected = QStyleOptionToolBox.NotAdjacent
font = kwargs.pop("font", None)
palette = kwargs.pop("palette", None)
super().__init__(*args, **kwargs)
if font is None:
self.setFont(QApplication.font("QAbstractButton"))
self.setAttribute(Qt.WA_SetFont, False)
else:
self.setFont(font)
if palette is None:
self.setPalette(QApplication.palette("QAbstractButton"))
self.setAttribute(Qt.WA_SetPalette, False)
else:
self.setPalette(palette)
def enterEvent(self, event):
super().enterEvent(event)
self.update()
def leaveEvent(self, event):
super().leaveEvent(event)
self.update()
def paintEvent(self, event):
if self.__nativeStyling:
super().paintEvent(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()
foregroundrole = QPalette.ButtonText
if opt.state & QStyle.State_Sunken:
# State 'down' pressed during a mouse press (slightly darker).
background_brush = brush_darker(brush_highlight, 110)
foregroundrole = QPalette.HighlightedText
elif opt.state & QStyle.State_MouseOver:
background_brush = brush_darker(brush_highlight, 95)
foregroundrole = QPalette.HighlightedText
elif opt.state & QStyle.State_On:
background_brush = brush_highlight
foregroundrole = QPalette.HighlightedText
else:
# The default button brush.
background_brush = palette.button()
rect = opt.rect
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 == QStyleOptionToolBox.OnlyOneTab or \
self.position == QStyleOptionToolBox.Beginning or \
self.selected & \
QStyleOptionToolBox.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(foregroundrole)))
p.setFont(opt.font)
p.drawText(text_rect,
int(Qt.AlignVCenter | Qt.AlignLeft) | \
int(Qt.TextSingleLine),
text)
if not opt.icon.isNull():
if opt.state & QStyle.State_Enabled:
mode = QIcon.Normal
else:
mode = QIcon.Disabled
if opt.state & QStyle.State_On:
state = QIcon.On
else:
state = QIcon.Off
icon_area_rect = icon_area_rect
icon_rect = QRect(QPoint(0, 0), opt.iconSize)
icon_rect.moveCenter(icon_area_rect.center())
opt.icon.paint(p, icon_rect, Qt.AlignCenter, mode, state)
p.restore()
class _ToolBoxLayout(QVBoxLayout):
def __init__(self, *args, **kwargs):
self.__minimumSize = None
self.__maximumSize = None
super().__init__(*args, **kwargs)
def minimumSize(self):
"""Reimplemented from `QBoxLayout.minimimSize`."""
if self.__minimumSize is None:
msize = super().minimumSize()
# Extend the minimum size by including the minimum width of
# hidden widgets (which QBoxLayout ignores), so the minimum
# width does not depend on the tab open/close state.
for i in range(self.count()):
item = self.itemAt(i)
if item.isEmpty() and item.widget() is not None:
msize.setWidth(max(item.widget().minimumWidth(),
msize.width()))
self.__minimumSize = msize
return self.__minimumSize
def maximumSize(self):
"""Reimplemented from `QBoxLayout.maximumSize`."""
msize = super().maximumSize()
# Allow the contents to grow horizontally (expand within the
# containing scroll area - joining the tab buttons to the
# right edge), but have a suitable maximum height (displaying an
# empty area on the bottom if the contents are smaller then the
# viewport).
msize.setWidth(QWIDGETSIZE_MAX)
return msize
def invalidate(self):
"""Reimplemented from `QVBoxLayout.invalidate`."""
self.__minimumSize = None
self.__maximumSize = None
super().invalidate()
[docs]class ToolBox(QFrame):
"""
A tool box widget.
"""
# Emitted when a tab is toggled.
tabToggled = Signal(int, bool)
[docs] def setExclusive(self, exclusive):
"""
Set exclusive tabs (only one tab can be open at a time).
"""
if self.__exclusive != exclusive:
self.__exclusive = exclusive
self.__tabActionGroup.setExclusive(exclusive)
checked = self.__tabActionGroup.checkedAction()
if checked is None:
# The action group can be out of sync with the actions state
# when switching between exclusive states.
actions_checked = [page.action for page in self.__pages
if page.action.isChecked()]
if actions_checked:
checked = actions_checked[0]
# Trigger/toggle remaining open pages
if exclusive and checked is not None:
for page in self.__pages:
if checked != page.action and page.action.isChecked():
page.action.trigger()
exclusive_ = Property(bool,
fget=exclusive,
fset=setExclusive,
designable=True,
doc="Exclusive tabs")
def __init__(self, parent=None, **kwargs):
super().__init__(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",
sizePolicy=QSizePolicy(QSizePolicy.MinimumExpanding,
QSizePolicy.MinimumExpanding),
verticalScrollBarPolicy=Qt.ScrollBarAlwaysOn,
horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOff,
widgetResizable=True,
)
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")
self.__contentsLayout = _ToolBoxLayout(
sizeConstraint=_ToolBoxLayout.SetMinAndMaxSize,
spacing=0
)
self.__contentsLayout.setContentsMargins(0, 0, 0, 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.__actionMapper = QSignalMapper(self)
self.__actionMapper.mapped[QObject].connect(self.__onTabActionToggled)
[docs] 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)
[docs] def tabButtonHeight(self):
"""
Return the tab button height.
"""
return self.__tabButtonHeight
[docs] 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)
[docs] def tabButton(self, index):
"""
Return the tab button at `index`
"""
return self.__pages[index].button
[docs] def tabAction(self, index):
"""
Return open/close action for the tab at `index`.
"""
return self.__pages[index].action
[docs] def addItem(self, widget, text, icon=None, toolTip=None):
"""
Append the `widget` in a new tab and return its index.
Parameters
----------
widget : :class:`QWidget`
A widget to be inserted. The toolbox takes ownership
of the widget.
text : str
Name/title of the new tab.
icon : :class:`QIcon`, optional
An icon for the tab button.
toolTip : str, optional
Tool tip for the tab button.
"""
return self.insertItem(self.count(), widget, text, icon, toolTip)
[docs] def insertItem(self, index, widget, text, icon=None, toolTip=None):
"""
Insert the `widget` in a new tab at position `index`.
See also
--------
ToolBox.addItem
"""
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
[docs] def removeItem(self, index):
"""
Remove the widget at `index`.
.. note:: The widget hidden but is is not deleted.
"""
self.__contentsLayout.takeAt(2 * index + 1)
self.__contentsLayout.takeAt(2 * index)
page = self.__pages.pop(index)
# Update the page indexes
for i in range(index, self.count()):
self.__pages[i] = self.__pages[i]._replace(index=i)
page.button.deleteLater()
# Hide the widget and reparent to self
# This follows QToolBox.removeItem
page.widget.hide()
page.widget.setParent(self)
self.__updatePositions()
self.__updateSelected()
self.updateGeometry()
[docs] def count(self):
"""
Return the number of widgets inserted in the toolbox.
"""
return len(self.__pages)
[docs] def widget(self, index):
"""
Return the widget at `index`.
"""
return self.__pages[index].widget
[docs] 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)
self.__actionMapper.setMapping(action, action)
action.toggled.connect(self.__actionMapper.map)
button = ToolBoxTabButton(self, objectName="toolbox-tab-button")
button.setDefaultAction(action)
button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
button.setSizePolicy(QSizePolicy.Ignored,
QSizePolicy.Fixed)
if self.__tabIconSize.isValid():
button.setIconSize(self.__tabIconSize)
if self.__tabButtonHeight > 0:
button.setFixedHeight(self.__tabButtonHeight)
return button
[docs] def ensureWidgetVisible(self, child, xmargin=50, ymargin=50):
"""
Scroll the contents so child widget instance is visible inside
the viewport.
"""
self.__scrollArea.ensureWidgetVisible(child, xmargin, ymargin)
[docs] def sizeHint(self):
hint = self.__contentsLayout.sizeHint()
if self.count():
# Compute max width of hidden widgets also.
scroll = self.__scrollArea
scroll_w = scroll.verticalScrollBar().sizeHint().width()
frame_w = self.frameWidth() * 2 + scroll.frameWidth() * 2
max_w = max([p.widget.sizeHint().width() for p in self.__pages])
hint = QSize(max(max_w, hint.width()) + scroll_w + frame_w,
hint.height())
return QSize(200, 200).expandedTo(hint)
def __onTabActionToggled(self, action):
page = find(self.__pages, action, key=attrgetter("action"))
on = action.isChecked()
page.widget.setVisible(on)
index = page.index
if index > 0:
# Update the `previous` tab buttons style hints
previous = self.__pages[index - 1].button
flag = QStyleOptionToolBox.NextIsSelected
if on:
previous.selected |= flag
else:
previous.selected &= ~flag
previous.update()
if index < self.count() - 1:
next = self.__pages[index + 1].button
flag = QStyleOptionToolBox.PreviousIsSelected
if on:
next.selected |= flag
else:
next.selected &= ~flag
next.update()
self.tabToggled.emit(index, on)
self.__contentsLayout.invalidate()
def __updateSelected(self):
"""Update the tab buttons selected style flags.
"""
if self.count() == 0:
return
opt = QStyleOptionToolBox
def update(button, next_sel, prev_sel):
if next_sel:
button.selected |= opt.NextIsSelected
else:
button.selected &= ~opt.NextIsSelected
if prev_sel:
button.selected |= opt.PreviousIsSelected
else:
button.selected &= ~ opt.PreviousIsSelected
button.update()
if self.count() == 1:
update(self.__pages[0].button, False, False)
elif self.count() >= 2:
pages = self.__pages
for i in range(1, self.count() - 1):
update(pages[i].button,
pages[i + 1].action.isChecked(),
pages[i - 1].action.isChecked())
def __updatePositions(self):
"""Update the tab buttons position style flags.
"""
if self.count() == 0:
return
elif self.count() == 1:
self.__pages[0].button.position = QStyleOptionToolBox.OnlyOneTab
else:
self.__pages[0].button.position = QStyleOptionToolBox.Beginning
self.__pages[-1].button.position = QStyleOptionToolBox.End
for p in self.__pages[1:-1]:
p.button.position = QStyleOptionToolBox.Middle
for p in self.__pages:
p.button.update()
def identity(arg):
return arg
def find(iterable, *what, **kwargs):
"""
find(iterable, [what, [key=None, [predicate=operator.eq]]])
"""
if what:
what = what[0]
key, predicate = kwargs.get("key", identity), kwargs.get("predicate", eq)
for item in iterable:
item_key = key(item)
if predicate(item_key, what):
return item
else:
raise ValueError(what)