Source code for orangecanvas.gui.toolgrid

"""
A widget containing a grid of clickable actions/buttons.
"""
import sys
from collections import deque

from typing import NamedTuple, List, Iterable, Optional, Any, Union, cast

from AnyQt.QtWidgets import (
    QFrame, QAction, QToolButton, QGridLayout, QSizePolicy,
    QStyleOptionToolButton, QStylePainter, QStyle, QApplication,
    QWidget
)
from AnyQt.QtGui import (
    QFont, QFontMetrics, QActionEvent, QPaintEvent, QResizeEvent,
)
from AnyQt.QtCore import Qt, QObject, QSize, QEvent, QSignalMapper
from AnyQt.QtCore import Signal, Slot

from orangecanvas.registry import WidgetDescription

__all__ = [
    "ToolGrid"
]

_ToolGridSlot = NamedTuple(
    "_ToolGridSlot", (
        ("button", QToolButton),
        ("action", QAction),
        ("row", int),
        ("column", int),
    )
)


def qfont_scaled(font, factor):
    # type: (QFont, float) -> QFont
    scaled = QFont(font)
    if font.pointSizeF() != -1:
        scaled.setPointSizeF(font.pointSizeF() * factor)
    elif font.pixelSize() != -1:
        scaled.setPixelSize(int(font.pixelSize() * factor))
    return scaled


class ToolGridButton(QToolButton):
    def __init__(self, parent=None, **kwargs):
        # type: (Optional[QWidget], Any) -> None
        super().__init__(parent, **kwargs)
        self.__text = ""
        self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
        if sys.platform != "darwin":
            font = QApplication.font("QWidget")
            self.setFont(qfont_scaled(font, 0.85))
            self.setAttribute(Qt.WA_SetFont, False)

    def actionEvent(self, event):
        # type: (QActionEvent) -> None
        super().actionEvent(event)
        if event.type() == QEvent.ActionChanged or \
                event.type() == QEvent.ActionAdded:
            self.__textLayout()

    def resizeEvent(self, event):
        # type: (QResizeEvent) -> None
        super().resizeEvent(event)
        self.__textLayout()

    def __textLayout(self):
        # type:  () -> None
        fm = self.fontMetrics()
        desc = self.defaultAction().data()
        if isinstance(desc, WidgetDescription) and desc.short_name:
            self.__text = desc.short_name
            return
        text = self.defaultAction().text()
        words = text.split()

        option = QStyleOptionToolButton()
        option.initFrom(self)

        margin = self.style().pixelMetric(QStyle.PM_ButtonMargin, option, self)
        min_width = self.width() - 2 * margin

        lines = []

        if fm.boundingRect(" ".join(words)).width() <= min_width or len(words) <= 1:
            lines = [" ".join(words)]
        else:
            best_w, best_l = sys.maxsize, ['', '']
            for i in range(1, len(words)):
                l1 = " ".join(words[:i])
                l2 = " ".join(words[i:])
                width = max(
                    fm.boundingRect(l1).width(),
                    fm.boundingRect(l2).width()
                )
                if width < best_w:
                    best_w = width
                    best_l = [l1, l2]
            lines = best_l

        # elide the end of each line if too long
        lines = [
            fm.elidedText(l, Qt.ElideRight, self.width() - margin)
            for l in lines
        ]

        text = "\n".join(lines)
        text = text.replace('&', '&&')  # Need escaped ampersand to show

        self.__text = text

    def paintEvent(self, event):
        # type: (QPaintEvent) -> None
        p = QStylePainter(self)
        opt = QStyleOptionToolButton()
        self.initStyleOption(opt)
        p.drawComplexControl(QStyle.CC_ToolButton, opt)
        p.end()

    def initStyleOption(self, option):
        # type: (QStyleOptionToolButton) -> None
        super().initStyleOption(option)
        if self.__text:
            option.text = self.__text

    def sizeHint(self):
        # type: () -> QSize
        opt = QStyleOptionToolButton()
        self.initStyleOption(opt)
        style = self.style()
        csize = opt.iconSize  # type: QSize
        fm = opt.fontMetrics  # type: QFontMetrics
        margin = style.pixelMetric(QStyle.PM_ButtonMargin)
        # content size is:
        #   * vertical: icon + margin + 2 * font ascent
        #   * horizontal: icon * 3 / 2

        csize.setHeight(csize.height() + margin + 2 * fm.lineSpacing())
        csize.setWidth(csize.width() * 3 // 2)
        size = style.sizeFromContents(
            QStyle.CT_ToolButton, opt, csize, self)
        return size


[docs]class ToolGrid(QFrame): """ A widget containing a grid of actions/buttons. Actions can be added using standard :func:`QWidget.addAction(QAction)` and :func:`QWidget.insertAction(int, QAction)` methods. Parameters ---------- parent : :class:`QWidget` Parent widget. columns : int Number of columns in the grid layout. buttonSize : QSize Size of tool buttons in the grid. iconSize : QSize Size of icons in the buttons. toolButtonStyle : :class:`Qt.ToolButtonStyle` Tool button style. """ #: Signal emitted when an action is triggered actionTriggered = Signal(QAction) #: Signal emitted when an action is hovered actionHovered = Signal(QAction) def __init__(self, parent=None, columns=4, buttonSize=QSize(), iconSize=QSize(), toolButtonStyle=Qt.ToolButtonTextUnderIcon, **kwargs): # type: (Optional[QWidget], int, QSize, QSize, Qt.ToolButtonStyle, Any) -> None sizePolicy = kwargs.pop("sizePolicy", None) # type: Optional[QSizePolicy] super().__init__(parent, **kwargs) if buttonSize is None: buttonSize = QSize() if iconSize is None: iconSize = QSize() self.__columns = columns self.__buttonSize = QSize(buttonSize) self.__iconSize = QSize(iconSize) self.__toolButtonStyle = toolButtonStyle self.__gridSlots = [] # type: List[_ToolGridSlot] self.__mapper = QSignalMapper() self.__mapper.mappedObject.connect(self.__onClicked) layout = QGridLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.setColumnStretch(columns - 1, 1000) self.setLayout(layout) if sizePolicy is None: self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) self.setAttribute(Qt.WA_WState_OwnSizePolicy, True) else: self.setSizePolicy(sizePolicy)
[docs] def setButtonSize(self, size): # type: (QSize) -> None """ Set the button size. """ if self.__buttonSize != size: self.__buttonSize = QSize(size) for slot in self.__gridSlots: slot.button.setFixedSize(size)
[docs] def buttonSize(self): # type: () -> QSize """ Return the button size. """ return QSize(self.__buttonSize)
[docs] def setIconSize(self, size): # type: (QSize) -> None """ Set the button icon size. The default icon size is style defined. """ if self.__iconSize != size: self.__iconSize = QSize(size) size = self.__effectiveIconSize() for slot in self.__gridSlots: slot.button.setIconSize(size)
[docs] def iconSize(self): # type: () -> QSize """ Return the icon size. If no size is set a default style defined size is returned. """ return self.__effectiveIconSize()
def __effectiveIconSize(self): # type: () -> QSize if not self.__iconSize.isValid(): opt = QStyleOptionToolButton() opt.initFrom(self) s = self.style().pixelMetric(QStyle.PM_LargeIconSize, opt, None) return QSize(s, s) else: return QSize(self.__iconSize)
[docs] def changeEvent(self, event): # type: (QEvent) -> None if event.type() == QEvent.StyleChange: size = self.__effectiveIconSize() for item in self.__gridSlots: item.button.setIconSize(size) super().changeEvent(event)
[docs] def setToolButtonStyle(self, style): # type: (Qt.ToolButtonStyle) -> None """ Set the tool button style. """ if self.__toolButtonStyle != style: self.__toolButtonStyle = style for slot in self.__gridSlots: slot.button.setToolButtonStyle(style)
[docs] def toolButtonStyle(self): # type: () -> Qt.ToolButtonStyle """ Return the tool button style. """ return self.__toolButtonStyle
[docs] def setColumnCount(self, columns): # type: (int) -> None """ Set the number of button/action columns. """ if self.__columns != columns: layout = cast(QGridLayout, self.layout()) layout.setColumnStretch(self.__columns - 1, 0) layout.setColumnStretch(columns - 1, 1000) self.__columns = columns self.__relayout()
[docs] def columns(self): # type: () -> int """ Return the number of columns in the grid. """ return self.__columns
[docs] def clear(self): # type: () -> None """ Clear all actions/buttons. """ for slot in reversed(list(self.__gridSlots)): self.removeAction(slot.action) self.__gridSlots = []
[docs] def insertAction(self, before, action): # type: (Union[QAction, int], QAction) -> None """ Insert a new action at the position currently occupied by `before` (can also be an index). Parameters ---------- before : :class:`QAction` or int Position where the `action` should be inserted. action : :class:`QAction` Action to insert """ if isinstance(before, int): actions = list(self.actions()) if len(actions) == 0 or before >= len(actions): # Insert as the first action or the last action. return self.addAction(action) before = actions[before] return super().insertAction(before, action)
[docs] def setActions(self, actions): # type: (Iterable[QAction]) -> None """ Clear the grid and add `actions`. """ self.clear() for action in actions: self.addAction(action)
[docs] def buttonForAction(self, action): # type: (QAction) -> QToolButton """ Return the :class:`QToolButton` instance button for `action`. """ actions = [slot.action for slot in self.__gridSlots] index = actions.index(action) return self.__gridSlots[index].button
[docs] def createButtonForAction(self, action): # type: (QAction) -> QToolButton """ Create and return a :class:`QToolButton` for action. """ button = ToolGridButton(self) button.setDefaultAction(action) if self.__buttonSize.isValid(): button.setFixedSize(self.__buttonSize) button.setIconSize(self.__effectiveIconSize()) button.setToolButtonStyle(self.__toolButtonStyle) button.setProperty("tool-grid-button", True) return button
[docs] def count(self): # type: () -> int """ Return the number of buttons/actions in the grid. """ return len(self.__gridSlots)
[docs] def actionEvent(self, event): # type: (QActionEvent) -> None super().actionEvent(event) if event.type() == QEvent.ActionAdded: # Note: the action is already in the self.actions() list. actions = list(self.actions()) index = actions.index(event.action()) self.__insertActionButton(index, event.action()) elif event.type() == QEvent.ActionRemoved: self.__removeActionButton(event.action())
def __insertActionButton(self, index, action): # type: (int, QAction) -> None """Create a button for the action and add it to the layout at index. """ self.__shiftGrid(index, 1) button = self.createButtonForAction(action) row = index // self.__columns column = index % self.__columns layout = cast(QGridLayout, self.layout()) layout.addWidget(button, row, column, alignment=Qt.AlignTop | Qt.AlignLeft) self.__gridSlots.insert( index, _ToolGridSlot(button, action, row, column) ) self.__mapper.setMapping(button, action) button.clicked.connect(self.__mapper.map) button.installEventFilter(self) def __removeActionButton(self, action): # type: (QAction) -> None """Remove the button for the action from the layout and delete it. """ actions = [slot.action for slot in self.__gridSlots] index = actions.index(action) slot = self.__gridSlots.pop(index) slot.button.removeEventFilter(self) self.__mapper.removeMappings(slot.button) self.layout().removeWidget(slot.button) self.__shiftGrid(index + 1, -1) slot.button.deleteLater() def __shiftGrid(self, start, count=1): # type: (int, int) -> None """Shift all buttons starting at index `start` by `count` cells. """ layout = cast(QGridLayout, self.layout()) cell_count = layout.rowCount() * layout.columnCount() columns = self.__columns direction = 1 if count >= 0 else -1 if direction == 1: start, end = cell_count - 1, start - 1 else: start, end = start, cell_count for index in range(start, end, -direction): item = layout.itemAtPosition( index // columns, index % columns ) if item: button = item.widget() new_index = index + count layout.addWidget( button, new_index // columns, new_index % columns, Qt.AlignLeft | Qt.AlignTop ) def __relayout(self): # type: () -> None """Relayout the buttons. """ layout = cast(QGridLayout, self.layout()) for i in reversed(range(layout.count())): layout.takeAt(i) self.__gridSlots = [ _ToolGridSlot(slot.button, slot.action, i // self.__columns, i % self.__columns) for i, slot in enumerate(self.__gridSlots) ] for slot in self.__gridSlots: layout.addWidget(slot.button, slot.row, slot.column) def __indexOf(self, button): # type: (QWidget) -> int """Return the index of button widget. """ buttons = [slot.button for slot in self.__gridSlots] return buttons.index(button) def __onButtonEnter(self, button): # type: (QToolButton) -> None action = button.defaultAction() self.actionHovered.emit(action) @Slot(QObject) def __onClicked(self, action): # type: (QAction) -> None assert isinstance(action, QAction) self.actionTriggered.emit(action)
[docs] def eventFilter(self, obj, event): # type: (QObject, QEvent) -> bool etype = event.type() if etype == QEvent.KeyPress and obj.hasFocus(): key = event.key() if key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right]: if self.__focusMove(obj, key): event.accept() return True elif etype == QEvent.HoverEnter and obj.parent() is self: self.__onButtonEnter(obj) return super().eventFilter(obj, event)
[docs] def focusNextPrevChild(self, next: bool) -> bool: return self.__focusMove( self.focusWidget(), Qt.Key_Right if next else Qt.Key_Left )
def __focusMove(self, focus, key): # type: (QWidget, Qt.Key) -> bool assert focus is self.focusWidget() try: index = self.__indexOf(focus) except IndexError: return False if key == Qt.Key_Down: index += self.__columns elif key == Qt.Key_Up: index -= self.__columns elif key == Qt.Key_Left: index -= 1 elif key == Qt.Key_Right: index += 1 if 0 <= index < self.count(): button = self.__gridSlots[index].button button.setFocus(Qt.TabFocusReason) return True else: return False
[docs] def sizeHint(self) -> QSize: sh = super().sizeHint() if self.__buttonSize.isValid(): width = self.__buttonSize.width() else: option = QStyleOptionToolButton() option.initFrom(self) option.iconSize = self.iconSize() option.toolButtonStyle = self.toolButtonStyle() csize = QSize(option.iconSize) csize.setWidth(csize.width() * 3 // 2) # see ToolGridButton size = self.style().sizeFromContents(QStyle.CT_ToolButton, option, csize, None) width = size.width() layout = self.layout() spacing = layout.horizontalSpacing() columns = self.__columns width = width * columns + (max(columns - 1, 0) * spacing) sh.setWidth(max(sh.width(), width)) return sh