Source code for orangecanvas.document.quickmenu

"""
==========
Quick Menu
==========

A :class:`QuickMenu` widget provides lists of actions organized in tabs
with a quick search functionality.

"""
import typing
import statistics
import sys
import logging
import warnings

from collections import namedtuple

from typing import Optional, Any, List, Callable

from AnyQt.QtWidgets import (
    QWidget, QFrame, QToolButton, QAbstractButton, QAction, QTreeView,
    QButtonGroup, QStackedWidget, QHBoxLayout, QVBoxLayout, QSizePolicy,
    QStyleOptionToolButton, QStylePainter, QStyle, QApplication,
    QStyleOptionViewItem, QSizeGrip, QAbstractItemView, QStyledItemDelegate
)
from AnyQt.QtGui import (
    QIcon, QStandardItemModel, QPolygon, QRegion, QBrush, QPalette,
    QPaintEvent, QColor, QPainter, QMouseEvent
)
from AnyQt.QtCore import (
    Qt, QObject, QPoint, QPointF, QSize, QRect, QRectF, QEventLoop, QEvent,
    QModelIndex, QTimer, QRegularExpression, QSortFilterProxyModel,
    QItemSelectionModel, QAbstractItemModel, QSettings
)
from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property

from .usagestatistics import UsageStatistics
from ..gui.framelesswindow import FramelessWindow
from ..gui.lineedit import LineEdit
from ..gui.tooltree import ToolTree, FlattenedTreeItemModel
from ..gui.utils import StyledWidget_paintEvent, innerGlowBackgroundPixmap, innerShadowPixmap
from ..registry.qt import QtWidgetRegistry

from ..resources import icon_loader

log = logging.getLogger(__name__)


class _MenuItemDelegate(QStyledItemDelegate):
    def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex):
        widget = option.widget
        if widget is not None:
            style = widget.style()
        else:
            style = QApplication.style()

        opt = QStyleOptionViewItem(option)
        self.initStyleOption(opt, index)
        rect = option.rect
        tl = rect.topLeft()
        br = rect.bottomRight()
        """ Draw icon background """

        # get category color
        brush = as_qbrush(index.data(QtWidgetRegistry.BACKGROUND_ROLE))
        if brush is not None:
            color = brush.color()
        else:
            color = QColor("FFA840")  # orange!

        # (get) cache(d) pixmap
        bg = innerGlowBackgroundPixmap(color,
                                       QSize(rect.height(), rect.height()))

        # draw background
        bgRect = QRect(tl.x(), tl.y(), rect.height(), rect.height())
        painter.drawPixmap(bgRect, bg, bg.rect())

        """ Draw icon """

        # get item decoration (icon)
        dec = opt.icon
        decSize = option.decorationSize  # use as approximate/minimum size
        x = rect.left() + rect.height() / 2 - decSize.width() / 2
        y = rect.top() + rect.height() / 2 - decSize.height() / 2

        # decoration rect, where the icon is drawn
        decTl = QPointF(x, y)
        decBr = QPointF(x + decSize.width(), y + decSize.height())
        decRect = QRectF(decTl, decBr)

        # draw icon pixmap
        dec.paint(painter, decRect.toAlignedRect())

        # draw display
        rect = QRect(opt.rect)
        rect.setLeft(bgRect.left() + bgRect.width())  # move to icon area end
        opt.rect = rect
        # no focus display (selected state is the sole indicator)
        opt.state &= ~ QStyle.State_KeyboardFocusChange
        opt.state &= ~ QStyle.State_HasFocus
        # no icon
        opt.decorationSize = QSize()
        opt.icon = QIcon()
        opt.features &= ~QStyleOptionViewItem.HasDecoration
        if not opt.state & QStyle.State_Selected:
            style.drawControl(QStyle.CE_ItemViewItem, opt, painter, widget)
            return
        # draw as 2 side by side items, first with the actual text,
        # the second with 'enter key' shortcut indicator
        optleft = QStyleOptionViewItem(opt)
        optright = QStyleOptionViewItem(opt)

        optright.decorationSize = QSize()
        optright.icon = QIcon()
        optright.features &= ~QStyleOptionViewItem.HasDecoration
        optright.viewItemPosition = QStyleOptionViewItem.End
        optright.textElideMode = Qt.ElideNone
        optright.text = "\u21B5"
        sh = style.sizeFromContents(
            QStyle.CT_ItemViewItem, optright, QSize(), widget)
        rectright = QRect(opt.rect)
        rectright.setLeft(rectright.left() + rectright.width() - sh.width())
        optright.rect = rectright

        rectleft = QRect(opt.rect)
        rectleft.setRight(rectright.left())
        optleft.rect = rectleft
        optleft.viewItemPosition = QStyleOptionViewItem.Beginning
        optleft.textElideMode = Qt.ElideRight

        style.drawControl(QStyle.CE_ItemViewItem, optright, painter, widget)
        style.drawControl(QStyle.CE_ItemViewItem, optleft, painter, widget)

    def sizeHint(self, option, index):
        # type: (QStyleOptionViewItem, QModelIndex) -> QSize
        if option.widget is not None:
            style = option.widget.style()
        else:
            style = QApplication.style()
        opt = QStyleOptionViewItem(option)
        self.initStyleOption(opt, index)

        # content size without the icon
        optnoicon = QStyleOptionViewItem(opt)
        optnoicon.decorationSize = QSize()
        optnoicon.icon = QIcon()
        optnoicon.features &= ~QStyleOptionViewItem.HasDecoration
        sh = style.sizeFromContents(
            QStyle.CT_ItemViewItem, optnoicon, QSize(), option.widget
        )
        # size with the icon
        shicon = style.sizeFromContents(
            QStyle.CT_ItemViewItem, opt, QSize(), option.widget
        )
        sh.setHeight(max(sh.height(), shicon.height(), 25))
        # add the custom drawn icon area rect to sh (height x height)
        sh.setWidth(sh.width() + sh.height())
        return sh





if typing.TYPE_CHECKING:
    FilterFunc = Callable[[QModelIndex], bool]


class ItemDisableFilter(QSortFilterProxyModel):
    """
    An filter proxy model used to disable selected items based on
    a filtering function.

    """
    def __init__(self, parent=None, **kwargs):
        # type: (Optional[QObject], Any) -> None
        super().__init__(parent, **kwargs)
        self.__filterFunc = None  # type: Optional[FilterFunc]

    def setFilterFunc(self, func):
        # type: (Optional[FilterFunc]) -> None
        """
        Set the filtering function.
        """
        if not (callable(func) or func is None):
            raise TypeError("A callable object or None expected.")

        if self.__filterFunc != func:
            self.__filterFunc = func
            # Mark the whole model as changed.
            self.dataChanged.emit(self.index(0, 0),
                                  self.index(self.rowCount(), 0))

    def flags(self, index):
        # type: (QModelIndex) -> Qt.ItemFlags
        """
        Reimplemented from :class:`QSortFilterProxyModel.flags`
        """
        source = self.mapToSource(index)
        flags = source.flags()

        if self.__filterFunc is not None:
            enabled = flags & Qt.ItemIsEnabled
            if enabled and not self.__filterFunc(source):
                flags = Qt.ItemFlags(flags ^ Qt.ItemIsEnabled)

        return flags


class SuggestMenuPage(MenuPage):
    """
    A MenuMage for the QuickMenu widget supporting item filtering
    (searching).

    """
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def setSearchQuery(self, text):
        """
        Called upon text edited in search query text box.
        """
        proxy = self.__proxy()
        proxy.setSearchQuery(text)

        # re-sorts to make sure items that match by title are on top
        proxy.invalidate()
        proxy.sort(0)
        self.ensureCurrent()

    def setModel(self, model):
        # type: (QAbstractItemModel) -> None
        """
        Reimplemented from :ref:`MenuPage.setModel`.
        """
        flat = FlattenedTreeItemModel(self)
        flat.setSourceModel(model)
        flat.setFlatteningMode(flat.InternalNodesDisabled)
        flat.setFlatteningMode(flat.LeavesOnly)
        proxy = SortFilterProxyModel(self)
        proxy.setFilterCaseSensitivity(Qt.CaseSensitive)
        proxy.setSourceModel(flat)
        # bypass MenuPage.setModel and its own proxy
        # TODO: store my self.__proxy
        ToolTree.setModel(self, proxy)
        self.ensureCurrent()

    def __proxy(self):
        # type: () -> SortFilterProxyModel
        model = self.view().model()
        assert isinstance(model, SortFilterProxyModel)
        assert model.parent() is self
        return model

    def setFilterFixedString(self, pattern):
        # type: (str) -> None
        """
        Set the fixed string filtering pattern. Only items which contain the
        `pattern` string will be shown.
        """
        proxy = self.__proxy()
        proxy.setFilterFixedString(pattern)
        self.ensureCurrent()

    def setFilterRegularExpression(self, pattern):
        # type: (QRegularExpression) -> None
        """
        Set the regular expression filtering pattern. Only items matching
        the `pattern` expression will be shown.
        """
        filter_proxy = self.__proxy()
        filter_proxy.setFilterRegularExpression(pattern)
        # re-sorts to make sure items that match by title are on top
        filter_proxy.invalidate()
        filter_proxy.sort(0)

        self.ensureCurrent()

    def setFilterFunc(self, func):
        # type: (Optional[FilterFunc]) -> None
        """
        Set a filtering function.
        """
        filter_proxy = self.__proxy()
        filter_proxy.setFilterFunc(func)

    def setSortingFunc(self, func):
        # type: (Callable[[Any, Any], bool]) -> None
        """
        Set a sorting function.
        """
        filter_proxy = self.__proxy()
        filter_proxy.setSortingFunc(func)


class SortFilterProxyModel(QSortFilterProxyModel):
    """
    An filter proxy model used to sort and filter items based on
    a sort and filtering function.

    """
    def __init__(self, parent=None):
        # type: (Optional[QObject]) -> None
        super().__init__(parent)

        self.__filterFunc = None  # type: Optional[FilterFunc]
        self.__sortingFunc = None

        self.__query = ''

    def setSearchQuery(self, text):
        """
        Set the search query, used for filtering and sorting widgets
        alongside the filter and sort functions.

        :type text: str
        """
        self.__query = text.lstrip().lower()

    def setFilterFunc(self, func):
        """
        Set the filtering function, used for filtering out widgets
        without compatible signals.

        :type func: Optional[FilterFunc]
        """
        if not (func is None or callable(func)):
            raise ValueError("A callable object or None expected.")

        if self.__filterFunc is not func:
            self.__filterFunc = func
            self.invalidateFilter()

    def filterFunc(self):
        # type: () -> Optional[FilterFunc]
        return self.__filterFunc

    def filterAcceptsRow(self, row, parent=QModelIndex()):
        # type: (int, QModelIndex) -> bool
        flat_model = self.sourceModel()
        index = flat_model.index(row, self.filterKeyColumn(), parent)
        description = flat_model.data(index, role=QtWidgetRegistry.WIDGET_DESC_ROLE)
        if description is None:
            return False

        name = description.name.lower()
        keywords = [k.lower() for k in description.keywords]
        for k in keywords[:]:
            if '-' in k:
                keywords.append(k.replace('-', ''))
                keywords.append(k.replace('-', ' '))

        query = self.__query
        # match name and keywords
        accepted = (not query or
                    query in name or
                    query in name.replace(' ', '') or
                    any(k.startswith(query) for k in keywords))

        # if matches query, apply filter function (compatibility with paired widget)
        if accepted and self.__filterFunc is not None:
            model = self.sourceModel()
            index = model.index(row, self.filterKeyColumn(), parent)
            return self.__filterFunc(index)
        else:
            return accepted

    def setSortingFunc(self, func):
        """
        Set the sorting function, used for sorting according to statistics.

        :type func: Callable[[Any, Any], bool]
        """
        self.__sortingFunc = func
        self.invalidate()
        self.sort(0)

    def sortingFunc(self):
        return self.__sortingFunc

    def lessThan(self, left, right):
        # type: (QModelIndex, QModelIndex) -> bool
        if self.__sortingFunc is None:
            return super().lessThan(left, right)
        model = self.sourceModel()
        left_data = model.data(left)
        right_data = model.data(right)
        flat_model = self.sourceModel()
        left_description = flat_model.data(left, role=QtWidgetRegistry.WIDGET_DESC_ROLE)
        right_description = flat_model.data(right, role=QtWidgetRegistry.WIDGET_DESC_ROLE)

        def eval_lessthan(predicate, left, right):
            left_match = predicate(left)
            right_match = predicate(right)
            # if one matches, we know the answer
            if left_match != right_match:
                return left_match
            # if both match, fallback to sorting func
            elif left_match and right_match:
                return self.__sortingFunc(left_data, right_data)
            # else, move on
            return None

        query = self.__query

        left_title = left_description.name.lower()
        right_title = right_description.name.lower()

        sorting_predicates = [
            lambda t: query == t,  # full title match
            lambda t: query == t.replace(' ', ''),  # full title match no spaces
            lambda t: t.startswith(query),  # startswith title match
            lambda t: t.replace(' ', '').startswith(query),  # startswith title match no spaces
        ]

        for p in sorting_predicates:
            match = eval_lessthan(p, left_title, right_title)
            if match is not None:
                return match

        return self.__sortingFunc(left_data, right_data)


class SearchWidget(LineEdit):
    def __init__(self, parent=None, **kwargs):
        # type: (Optional[QWidget], Any) -> None
        super().__init__(parent, **kwargs)
        self.setAttribute(Qt.WA_MacShowFocusRect, False)

        self.__setupUi()

    def __setupUi(self):
        icon = icon_loader().get("icons/Search.svg")
        action = QAction(icon, "Search", self)
        self.setAction(action, LineEdit.LeftPosition)

        button = self.button(SearchWidget.LeftPosition)
        button.setCheckable(True)

    def setChecked(self, checked):
        button = self.button(SearchWidget.LeftPosition)
        if button.isChecked() != checked:
            button.setChecked(checked)
            button.update()
            button.style().polish(button)  # QTBUG-2982


class MenuStackWidget(QStackedWidget):
    """
    Stack widget for the menu pages.
    """

    def sizeHint(self):
        # type: () -> QSize
        """
        Size hint is the maximum width and median height of the widgets
        contained in the stack.
        """
        default_size = QSize(200, 400)
        widget_hints = [default_size]
        for i in range(self.count()):
            hint = self.widget(i).sizeHint()
            widget_hints.append(hint)

        width = max([s.width() for s in widget_hints])

        if widget_hints:
            # Take the median for the height
            height = statistics.median([s.height() for s in widget_hints])
        else:
            height = default_size.height()
        return QSize(width, int(height))

    def __sizeHintForTreeView(self, view):
        # type: (QTreeView) -> QSize
        hint = view.sizeHint()
        model = view.model()

        count = model.rowCount()
        width = view.sizeHintForColumn(0)

        if count:
            height = view.sizeHintForRow(0)
            height = height * count
        else:
            height = hint.height()

        return QSize(max(width, hint.width()), max(height, hint.height()))


class TabButton(QToolButton):
    def __init__(self, parent=None, **kwargs):
        # type: (Optional[QWidget], Any) -> None
        super().__init__(parent, **kwargs)
        self.setToolButtonStyle(Qt.ToolButtonIconOnly)
        self.setCheckable(True)

        self.__flat = True
        self.__showMenuIndicator = False
        self.__shadowLength = 5
        self.__shadowColor = QColor("#000000")

        self.shadowPosition = 0

    def setShadowLength(self, shadowSize):
        if self.__shadowLength != shadowSize:
            self.__shadowLength = shadowSize
            self.update()

    def shadowLength(self):
        return self.__shadowLength

    shadowLength_ = Property(int, fget=shadowLength, fset=setShadowLength, designable=True)

    def setShadowColor(self, shadowColor):
        if self.__shadowColor != shadowColor:
            self.__shadowColor = shadowColor
            self.update()

    def shadowColor(self):
        return self.__shadowColor

    shadowColor_ = Property(QColor, fget=shadowColor, fset=setShadowColor, designable=True)

    def setFlat(self, flat):
        # type: (bool) -> None
        if self.__flat != flat:
            self.__flat = flat
            self.update()

    def flat(self):
        # type: () -> bool
        return self.__flat

    flat_ = Property(bool, fget=flat, fset=setFlat,
                     designable=True)

    def setShownMenuIndicator(self, show):
        # type: (bool) -> None
        if self.__showMenuIndicator != show:
            self.__showMenuIndicator = show
            self.update()

    def showMenuIndicator(self):
        # type: () -> bool
        return self.__showMenuIndicator

    showMenuIndicator_ = Property(bool, fget=showMenuIndicator,
                                  fset=setShownMenuIndicator,
                                  designable=True)

    def paintEvent(self, event):
        # type: (QPaintEvent) -> None
        opt = QStyleOptionToolButton()
        self.initStyleOption(opt)
        if self.__showMenuIndicator and self.isChecked():
            opt.features |= QStyleOptionToolButton.HasMenu
        if self.__flat:
            # Use default widget background/border styling.
            StyledWidget_paintEvent(self, event)

            p = QStylePainter(self)
            p.drawControl(QStyle.CE_ToolButtonLabel, opt)
        else:
            p = QStylePainter(self)
            p.drawComplexControl(QStyle.CC_ToolButton, opt)

        # if checked, no shadow
        if self.isChecked():
            return

        targetShadowRect = QRect(self.rect().x(), self.rect().y(), self.width(), self.height())

        shadow = innerShadowPixmap(self.__shadowColor,
                                   targetShadowRect.size(),
                                   self.shadowPosition,
                                   self.__shadowLength)

        p.drawPixmap(targetShadowRect, shadow, shadow.rect())

    def sizeHint(self):
        # type: () -> QSize
        opt = QStyleOptionToolButton()
        self.initStyleOption(opt)
        if self.__showMenuIndicator and self.isChecked():
            opt.features |= QStyleOptionToolButton.HasMenu
        style = self.style()
        hint = style.sizeFromContents(QStyle.CT_ToolButton, opt,
                                      opt.iconSize, self)
        # should there be no margin around the icon, add extra margin;
        # in the absence of a better alternative use the text <-> border margin of a push button
        margin = style.pixelMetric(QStyle.PM_ButtonMargin, None, self)
        width = max(hint.width(), opt.iconSize.width() + margin)
        height = max(hint.height(), opt.iconSize.height() + margin)
        hint.setWidth(width)
        hint.setHeight(height)
        return hint


_Tab = namedtuple(
    "_Tab",
    ["text",
     "icon",
     "toolTip",
     "button",
     "data",
     "palette"]
)


class TabBarWidget(QWidget):
    """
    A vertical tab bar widget using tool buttons as for tabs.
    """

    currentChanged = Signal(int)

    def __init__(self, parent=None, **kwargs):
        # type: (Optional[QWidget], Any) -> None
        super().__init__(parent, **kwargs)
        layout = QVBoxLayout()
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)
        self.setLayout(layout)

        self.setSizePolicy(QSizePolicy.Fixed,
                           QSizePolicy.Expanding)
        self.__tabs = []  # type: List[_Tab]

        self.__currentIndex = -1
        self.__changeOnHover = False

        self.__iconSize = QSize(26, 26)

        self.__group = QButtonGroup(self, exclusive=True)
        self.__group.buttonPressed[QAbstractButton].connect(
            self.__onButtonPressed
        )
        self.setMouseTracking(True)

        self.__sloppyButton = None  # type: Optional[QAbstractButton]
        self.__sloppyRegion = QRegion()
        self.__sloppyTimer = QTimer(self, singleShot=True)
        self.__sloppyTimer.timeout.connect(self.__onSloppyTimeout)

        self.currentChanged.connect(self.__updateShadows)

    def setChangeOnHover(self, changeOnHover):
        #  type: (bool) -> None
        """
        If set to ``True`` the tab widget will change the current index when
        the mouse hovers over a tab button.
        """
        if self.__changeOnHover != changeOnHover:
            self.__changeOnHover = changeOnHover

    def changeOnHover(self):
        # type: () -> bool
        """
        Does the current tab index follow the mouse cursor.
        """
        return self.__changeOnHover

    def count(self):
        # type: () -> int
        """
        Return the number of tabs in the widget.
        """
        return len(self.__tabs)

    def addTab(self, text, icon=QIcon(), toolTip=""):
        # type: (str, QIcon, str) -> int
        """
        Add a new tab and return it's index.
        """
        return self.insertTab(self.count(), text, icon, toolTip)

    def insertTab(self, index, text, icon=QIcon(), toolTip=""):
        # type: (int, str, QIcon, str) -> int
        """
        Insert a tab at `index`
        """
        button = TabButton(self, objectName="tab-button")
        button.setSizePolicy(QSizePolicy.Expanding,
                             QSizePolicy.Expanding)
        button.setIconSize(self.__iconSize)
        button.setMouseTracking(True)

        self.__group.addButton(button)

        button.installEventFilter(self)

        tab = _Tab(text, icon, toolTip, button, None, None)
        self.layout().insertWidget(index, button)

        if self.count() > 0:
            self.button(-1).setProperty('lastCategoryButton', False)
        self.__tabs.insert(index, tab)
        button.setProperty('lastCategoryButton', True)
        self.__updateTab(index)

        if self.currentIndex() == -1:
            self.setCurrentIndex(0)

        self.__updateShadows()

        return index

    def removeTab(self, index):
        # type: (int) -> None
        """
        Remove a tab at `index`.
        """
        if 0 <= index < self.count():
            tab = self.__tabs.pop(index)
            layout_index = self.layout().indexOf(tab.button)
            if layout_index != -1:
                self.layout().takeAt(layout_index)

            self.__group.removeButton(tab.button)

            tab.button.removeEventFilter(self)

            if tab.button is self.__sloppyButton:
                self.__sloppyButton = None
                self.__sloppyRegion = QRegion()

            tab.button.deleteLater()
            tab.button.setParent(None)

            if self.currentIndex() == index:
                if self.count():
                    self.setCurrentIndex(max(index - 1, 0))
                else:
                    self.setCurrentIndex(-1)

            self.__updateShadows()

    def setTabIcon(self, index, icon):
        # type: (int, QIcon) -> None
        """
        Set the `icon` for tab at `index`.
        """
        self.__tabs[index] = self.__tabs[index]._replace(icon=QIcon(icon))
        self.__updateTab(index)

    def setTabToolTip(self, index, toolTip):
        # type: (int, str) -> None
        """
        Set `toolTip` for tab at `index`.
        """
        self.__tabs[index] = self.__tabs[index]._replace(toolTip=toolTip)
        self.__updateTab(index)

    def setTabText(self, index, text):
        # type: (int, str) -> None
        """
        Set tab `text` for tab at `index`
        """
        self.__tabs[index] = self.__tabs[index]._replace(text=text)
        self.__updateTab(index)

    def setTabPalette(self, index, palette):
        # type: (int, QPalette) -> None
        """
        Set the tab button palette.
        """
        self.__tabs[index] = self.__tabs[index]._replace(palette=QPalette(palette))
        self.__updateTab(index)

    def setCurrentIndex(self, index):
        # type: (int) -> None
        """
        Set the current tab index.
        """
        if self.__currentIndex != index:
            self.__currentIndex = index

            self.__sloppyRegion = QRegion()
            self.__sloppyButton = None

            if index != -1:
                self.__tabs[index].button.setChecked(True)

            self.currentChanged.emit(index)

    def currentIndex(self):
        # type: () -> int
        """
        Return the current index.
        """
        return self.__currentIndex

    def button(self, index):
        # type: (int) -> QAbstractButton
        """
        Return the `TabButton` instance for index.
        """
        return self.__tabs[index].button

    def setIconSize(self, size):
        # type: (QSize) -> None
        if self.__iconSize != size:
            self.__iconSize = QSize(size)
            for tab in self.__tabs:
                tab.button.setIconSize(self.__iconSize)

    def __updateTab(self, index):
        # type: (int) -> None
        """
        Update the tab button.
        """
        tab = self.__tabs[index]
        b = tab.button

        if tab.text:
            b.setText(tab.text)

        if tab.icon is not None and not tab.icon.isNull():
            b.setIcon(tab.icon)

        if tab.palette:
            b.setPalette(tab.palette)

    def __updateShadows(self):
        currentIndex = self.currentIndex()

        buttons = [tab.button for tab in self.__tabs if tab.button.isVisibleTo(self.parent())]
        if not buttons:
            return

        # set right shadow
        buttonShadows = [2] * len(buttons)

        # if button not visible
        if self.__tabs[currentIndex].button not in buttons:
            belowChosen = aboveChosen = None
        else:
            i = currentIndex + 1
            belowChosen = self.__tabs[i].button if i < len(self.__tabs) else None

            i = currentIndex - 1
            aboveChosen = self.__tabs[i].button if i >= 0 else None

        for i in range(len(buttons)):
            button = buttons[i]
            if button is belowChosen:
                buttonShadows[i] |= 1
            if button is aboveChosen:
                buttonShadows[i] |= 4

            if buttonShadows[i] != button.shadowPosition:
                button.shadowPosition = buttonShadows[i]
                button.update()

    def __onButtonPressed(self, button):
        # type: (QAbstractButton) -> None
        for i, tab in enumerate(self.__tabs):
            if tab.button is button:
                self.setCurrentIndex(i)
                break

    def __calcSloppyRegion(self, current):
        # type: (QPoint) -> QRegion
        """
        Given a current mouse cursor position return a region of the widget
        where hover/move events should change the current tab only on a
        timeout.
        """
        p1 = current + QPoint(0, 2)
        p2 = current + QPoint(0, -2)
        p3 = self.pos() + QPoint(self.width()+10, 0)
        p4 = self.pos() + QPoint(self.width()+10, self.height())
        return QRegion(QPolygon([p1, p2, p3, p4]))

    def __setSloppyButton(self, button):
        # type: (QAbstractButton) -> None
        """
        Set the current sloppy button (a tab button inside sloppy region)
        and reset the sloppy timeout.
        """
        if not button.isChecked():
            self.__sloppyButton = button
            delay = self.style().styleHint(QStyle.SH_Menu_SubMenuPopupDelay, None)
            # The delay timeout is the same as used by Qt in the QMenu.
            self.__sloppyTimer.start(delay)
        else:
            self.__sloppyTimer.stop()

    def __onSloppyTimeout(self):
        # type: () -> None
        if self.__sloppyButton is not None:
            button = self.__sloppyButton
            self.__sloppyButton = None
            if not button.isChecked():
                index = [tab.button for tab in self.__tabs].index(button)
                self.setCurrentIndex(index)

    def eventFilter(self, receiver, event):
        if event.type() == QEvent.MouseMove and \
                isinstance(receiver, TabButton) and \
                self.__changeOnHover:
            pos = receiver.mapTo(self, event.pos())
            if self.__sloppyRegion.contains(pos):
                self.__setSloppyButton(receiver)
            else:
                if not receiver.isChecked():
                    index = [tab.button for tab in self.__tabs].index(receiver)
                    self.setCurrentIndex(index)
                #also update sloppy region if mouse is moved on the same icon
                self.__sloppyRegion = self.__calcSloppyRegion(pos)

        return super().eventFilter(receiver, event)

    def leaveEvent(self, event):
        self.__sloppyButton = None
        self.__sloppyRegion = QRegion()

        return super().leaveEvent(event)


class PagedMenu(QWidget):
    """
    Tabbed container for :class:`MenuPage` instances.
    """
    triggered = Signal(QAction)
    hovered = Signal(QAction)

    currentChanged = Signal(int)

    def __init__(self, parent=None, **kwargs):
        # type: (Optional[QWidget], Any) -> None
        super().__init__(parent, **kwargs)

        self.__currentIndex = -1

        layout = QHBoxLayout()
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)

        self.__tab = TabBarWidget(self)
        self.__tab.currentChanged.connect(self.setCurrentIndex)
        self.__tab.setChangeOnHover(True)

        self.__stack = MenuStackWidget(self)

        self.navigator = ItemViewKeyNavigator(self)

        layout.addWidget(self.__tab, alignment=Qt.AlignTop)
        layout.addWidget(self.__stack)

        self.setLayout(layout)

        self.update_from_settings()

    def addPage(self, page, title, icon=QIcon(), toolTip=""):
        # type: (QWidget, str, QIcon, str) -> int
        """
        Add a `page` to the menu and return its index.
        """
        return self.insertPage(self.count(), page, title, icon, toolTip)

    def insertPage(self, index, page, title, icon=QIcon(), toolTip=""):
        # type: (int, QWidget, str, QIcon, str) -> int
        """
        Insert `page` at `index`.
        """
        page.triggered.connect(self.triggered)
        page.hovered.connect(self.hovered)

        self.__stack.insertWidget(index, page)
        self.__tab.insertTab(index, title, icon, toolTip)
        return index

    def page(self, index):
        # type: (int) -> QWidget
        """
        Return the page at index.
        """
        return self.__stack.widget(index)

    def removePage(self, index):
        # type: (int) -> None
        """
        Remove the page at `index`.
        """
        page = self.__stack.widget(index)
        page.triggered.disconnect(self.triggered)
        page.hovered.disconnect(self.hovered)

        self.__stack.removeWidget(page)
        self.__tab.removeTab(index)

    def count(self):
        # type: () -> int
        """
        Return the number of pages.
        """
        return self.__stack.count()

    def setCurrentIndex(self, index):
        # type: (int) -> None
        """
        Set the current page index.
        """
        if self.__currentIndex != index:
            self.__currentIndex = index
            self.__tab.setCurrentIndex(index)
            self.__stack.setCurrentIndex(index)

            view = self.currentPage().view()
            self.navigator.setView(view)
            self.navigator.ensureCurrent()
            view.setFocus()

            self.currentChanged.emit(index)

    def currentIndex(self):
        # type: () -> int
        """
        Return the index of the current page.
        """
        return self.__currentIndex

    def nextPage(self):
        """
        Set current index to next index, if one exists.
        """
        index = self.currentIndex() + 1
        if index < self.__stack.count():
            self.setCurrentIndex(index)

    def previousPage(self):
        """
        Set current index to previous index, if one exists.
        """
        index = self.currentIndex() - 1
        if index >= 0:
            self.setCurrentIndex(index)

    def setCurrentPage(self, page):
        # type: (QWidget) -> None
        """
        Set `page` to be the current shown page.
        """
        index = self.__stack.indexOf(page)
        self.setCurrentIndex(index)

    def currentPage(self):
        # type: () -> QWidget
        """
        Return the current page.
        """
        return self.__stack.currentWidget()

    def setChangeOnHover(self, enabled):
        self.__tab.setChangeOnHover(enabled)

    def indexOf(self, page):
        # type: (QWidget) -> int
        """
        Return the index of `page`.
        """
        return self.__stack.indexOf(page)

    def tabButton(self, index):
        # type: (int) -> QAbstractButton
        """
        Return the tab button instance for index.
        """
        return self.__tab.button(index)

    def update_from_settings(self):
        settings = QSettings()
        showCategories = settings.value("quickmenu/show-categories", False, bool)

        if self.count() != 0 and not showCategories:
            self.setCurrentIndex(0)

        self.__tab.setVisible(showCategories)
        if showCategories:
            self.__tab._TabBarWidget__updateShadows()  # why must this be called manually?

        self.navigator.setCategoriesEnabled(showCategories)


def as_qbrush(value):
    # type: (Any) -> Optional[QBrush]
    if isinstance(value, QBrush):
        return value
    else:
        return None


# format with:
# {0} - inactive background
# {1} - active/checked/hover background
# {2} - shadow color
TAB_BUTTON_STYLE_TEMPLATE = """\
TabButton {{
    qproperty-flat_: false;
    qproperty-shadowColor_: {2};
    background: {0};
    border: none;
    border-right: 3px solid {0};
    border-bottom: 1px solid #9CACB4;
    border-top: 1px solid {0}
}}

TabButton:checked {{
    background: {1};
    border: none;
}}

TabButton[lastCategoryButton='true']:checked {{
    border-bottom: 1px solid #9CACB4;
}}
"""

# TODO: Cleanup the QuickMenu interface. It should not have a 'dual' public
# interface (i.e. as an item model view (`setModel` method) and `addPage`,
# ...)


[docs]class QuickMenu(FramelessWindow): """ A quick menu popup for the widgets. The widgets are set using :func:`QuickMenu.setModel` which must be a model as returned by :func:`QtWidgetRegistry.model` """ #: An action has been triggered in the menu. triggered = Signal(QAction) #: An action has been hovered in the menu hovered = Signal(QAction) def __init__(self, parent=None, **kwargs): # type: (Optional[QWidget], Any) -> None super().__init__(parent, **kwargs) self.setWindowFlags(self.windowFlags() | Qt.Popup) self.__filterFunc = None # type: Optional[FilterFunc] self.__sortingFunc = None # type: Optional[Callable[[Any, Any], bool]] self.setLayout(QVBoxLayout(self)) self.layout().setContentsMargins(6, 6, 6, 6) self.layout().setSpacing(self.radius()) self.__search = SearchWidget(self, objectName="search-line") self.__search.setPlaceholderText( self.tr("Search for a widget...") ) self.__search.setChecked(True) self.layout().addWidget(self.__search) self.__frame = QFrame(self, objectName="menu-frame") layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(2) self.__frame.setLayout(layout) self.layout().addWidget(self.__frame) self.__pages = PagedMenu(self, objectName="paged-menu") self.__pages.currentChanged.connect(self.setCurrentIndex) self.__pages.triggered.connect(self.triggered) self.__pages.hovered.connect(self.hovered) self.__frame.layout().addWidget(self.__pages) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) self.__suggestPage = SuggestMenuPage(self, objectName="suggest-page") self.__suggestPage.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE) self.__suggestPage.setIcon(icon_loader().get("icons/Search.svg")) self.__search.installEventFilter(self.__pages.navigator) self.__pages.navigator.setView(self.__suggestPage.view()) if sys.platform == "darwin": view = self.__suggestPage.view() view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True) # Don't show the focus frame because it expands into the tab bar. view.setAttribute(Qt.WA_MacShowFocusRect, False) i = self.addPage(self.tr("Quick Search"), self.__suggestPage) button = self.__pages.tabButton(i) button.setVisible(False) searchAction = self.__search.actionAt(SearchWidget.LeftPosition) searchAction.hovered.connect(self.triggerSearch) self.__pages.currentChanged.connect(lambda index: self.__search.setChecked(i == index)) self.__search.textEdited.connect(self.__on_textEdited) self.__grip = WindowSizeGrip(self) # type: Optional[WindowSizeGrip] self.__grip.raise_() self.__loop = None # type: Optional[QEventLoop] self.__model = None # type: Optional[QAbstractItemModel] self.setModel(QStandardItemModel()) self.__triggeredAction = None # type: Optional[QAction]
[docs] def setSizeGripEnabled(self, enabled): # type: (bool) -> None """ Enable the resizing of the menu with a size grip in a bottom right corner (enabled by default). """ if bool(enabled) != bool(self.__grip): if self.__grip: self.__grip.deleteLater() self.__grip = None else: self.__grip = WindowSizeGrip(self) self.__grip.raise_()
[docs] def sizeGripEnabled(self): # type: () -> bool """ Is the size grip enabled. """ return bool(self.__grip)
[docs] def addPage(self, name, page): # type: (str, MenuPage) -> int """ Add the `page` (:class:`MenuPage`) with `name` and return it's index. The `page.icon()` will be used as the icon in the tab bar. """ return self.insertPage(self.__pages.count(), name, page)
def insertPage(self, index, name, page): # type: (int, str, MenuPage) -> int icon = page.icon() tip = name if page.toolTip(): tip = page.toolTip() index = self.__pages.insertPage(index, page, name, icon, tip) # Route the page's signals page.triggered.connect(self.__onTriggered) page.hovered.connect(self.hovered) # All page views focus on the search LineEdit page.view().setFocusProxy(self.__search) return index
[docs] def createPage(self, index): # type: (QModelIndex) -> MenuPage """ Create a new page based on the contents of an index (:class:`QModeIndex`) item. """ page = MenuPage(self) page.setModel(index.model()) page.setRootIndex(index) view = page.view() if sys.platform == "darwin": view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True) # Don't show the focus frame because it expands into the tab # bar at the top. view.setAttribute(Qt.WA_MacShowFocusRect, False) name = str(index.data(Qt.DisplayRole)) page.setTitle(name) icon = index.data(Qt.DecorationRole) if isinstance(icon, QIcon): page.setIcon(icon) page.setToolTip(index.data(Qt.ToolTipRole)) return page
def __clear(self): # type: () -> None for i in range(self.__pages.count() - 1, 0, -1): self.__pages.removePage(i)
[docs] def setModel(self, model): # type: (QAbstractItemModel) -> None """ Set the model containing the actions. """ if self.__model is not None: self.__model.dataChanged.disconnect(self.__on_dataChanged) self.__model.rowsInserted.disconnect(self.__on_rowsInserted) self.__model.rowsRemoved.disconnect(self.__on_rowsRemoved) self.__clear() for i in range(model.rowCount()): index = model.index(i, 0) self.__insertPage(i + 1, index) self.__model = model self.__suggestPage.setModel(model) if model is not None: model.dataChanged.connect(self.__on_dataChanged) model.rowsInserted.connect(self.__on_rowsInserted) model.rowsRemoved.connect(self.__on_rowsRemoved)
def __on_dataChanged(self, topLeft, bottomRight): # type: (QModelIndex, QModelIndex) -> None parent = topLeft.parent() # Only handle top level item (categories). if not parent.isValid(): for row in range(topLeft.row(), bottomRight.row() + 1): index = topLeft.sibling(row, 0) # Note: the tab buttons are offest by 1 (to accommodate # the Suggest Page). button = self.__pages.tabButton(row + 1) brush = as_qbrush(index.data(QtWidgetRegistry.BACKGROUND_ROLE)) if brush is not None: base_color = brush.color() shadow_color = base_color.fromHsv(base_color.hsvHue(), base_color.hsvSaturation(), 100) button.setStyleSheet( TAB_BUTTON_STYLE_TEMPLATE.format (base_color.darker(110).name(), base_color.name(), shadow_color.name()) ) def __on_rowsInserted(self, parent, start, end): # type: (QModelIndex, int, int) -> None # Only handle top level item (categories). assert self.__model is not None if not parent.isValid(): for row in range(start, end + 1): index = self.__model.index(row, 0) self.__insertPage(row + 1, index) def __on_rowsRemoved(self, parent, start, end): # type: (QModelIndex, int, int) -> None # Only handle top level item (categories). if not parent.isValid(): for row in range(end, start - 1, -1): self.__removePage(row + 1) def __insertPage(self, row, index): # type: (int, QModelIndex) -> None page = self.createPage(index) page.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE) i = self.insertPage(row, page.title(), page) brush = as_qbrush(index.data(QtWidgetRegistry.BACKGROUND_ROLE)) if brush is not None: base_color = brush.color() shadow_color = base_color.fromHsv(base_color.hsvHue(), base_color.hsvSaturation(), 100) button = self.__pages.tabButton(i) button.setStyleSheet( TAB_BUTTON_STYLE_TEMPLATE.format (base_color.darker(110).name(), base_color.name(), shadow_color.name()) ) def __removePage(self, row): # type: (int) -> None page = self.__pages.page(row) page.triggered.disconnect(self.__onTriggered) page.hovered.disconnect(self.hovered) page.view().removeEventFilter(self) self.__pages.removePage(row)
[docs] def setSortingFunc(self, func): # type: (Optional[Callable[[Any, Any], bool]]) -> None """ Set a sorting function in the suggest (search) menu. """ if self.__sortingFunc != func: self.__sortingFunc = func for i in range(0, self.__pages.count()): page = self.__pages.page(i) if isinstance(page, SuggestMenuPage): page.setSortingFunc(func)
[docs] def setFilterFunc(self, func): # type: (Optional[FilterFunc]) -> None """ Set a filter function. """ if func != self.__filterFunc: self.__filterFunc = func for i in range(0, self.__pages.count()): self.__pages.page(i).setFilterFunc(func)
[docs] def popup(self, pos=None, searchText=""): # type: (Optional[QPoint], str) -> None """ Popup the menu at `pos` (in screen coordinates). 'Search' text field is initialized with `searchText` if provided. """ if pos is None: pos = QPoint() screen = QApplication.screenAt(pos) else: pos = QPoint(pos) screen = QApplication.screenAt(pos) # to avoid accidental category hovers, offset the quickmenu # these were calculated by hand, the actual values can't be grabbed # before showing the menu for the first time (and they're defined in qss) x_offset = 33 if self.__pages.navigator.categoriesEnabled(): x_offset += 33 pos.setX(pos.x() - x_offset) self.__clearCurrentItems() self.__search.setText(searchText) self.__suggestPage.setSearchQuery(searchText) self.__pages.setChangeOnHover(not bool(searchText.strip())) UsageStatistics.set_last_search_query(searchText) self.ensurePolished() if self.testAttribute(Qt.WA_Resized) and self.sizeGripEnabled(): size = self.size() else: size = self.sizeHint() settings = QSettings() ssize = settings.value('quickmenu/size', defaultValue=QSize(), type=QSize) if ssize.isValid(): size.setHeight(ssize.height()) size = size.expandedTo(self.minimumSizeHint()) if screen is None: screen = QApplication.primaryScreen() screen_geom = screen.availableGeometry() # Adjust the size to fit inside the screen. if size.height() > screen_geom.height(): size.setHeight(screen_geom.height()) if size.width() > screen_geom.width(): size.setWidth(screen_geom.width()) geom = QRect(pos, size) if geom.top() < screen_geom.top(): geom.setTop(screen_geom.top()) if geom.left() < screen_geom.left(): geom.setLeft(screen_geom.left()) bottom_margin = screen_geom.bottom() - geom.bottom() right_margin = screen_geom.right() - geom.right() if bottom_margin < 0: # Falls over the bottom of the screen, move it up. geom.translate(0, bottom_margin) # TODO: right to left locale if right_margin < 0: # Falls over the right screen edge, move it left. geom.translate(right_margin, 0) self.setGeometry(geom) self.show() self.setFocusProxy(self.__search)
[docs] def exec(self, pos=None, searchText=""): # type: (Optional[QPoint], str) -> Optional[QAction] """ Execute the menu at position `pos` (in global screen coordinates). Return the triggered :class:`QAction` or `None` if no action was triggered. 'Search' text field is initialized with `searchText` if provided. """ self.popup(pos, searchText) self.setFocus(Qt.PopupFocusReason) self.__triggeredAction = None self.__loop = QEventLoop() self.__loop.exec() self.__loop.deleteLater() self.__loop = None action = self.__triggeredAction self.__triggeredAction = None return action
def exec_(self, *args, **kwargs): warnings.warn( "exec_ is deprecated, use exec", DeprecationWarning, stacklevel=2 ) return self.exec(*args, **kwargs)
[docs] def hideEvent(self, event): """ Reimplemented from :class:`QWidget` """ settings = QSettings() settings.setValue('quickmenu/size', self.size()) super().hideEvent(event) if self.__loop: self.__loop.exit()
[docs] def setCurrentPage(self, page): # type: (MenuPage) -> None """ Set the current shown page to `page`. """ self.__pages.setCurrentPage(page)
[docs] def setCurrentIndex(self, index): # type: (int) -> None """ Set the current page index. """ self.__pages.setCurrentIndex(index)
def __clearCurrentItems(self): # type: () -> None """ Clear any selected (or current) items in all the menus. """ for i in range(self.__pages.count()): self.__pages.page(i).view().selectionModel().clear() def __onTriggered(self, action): # type: (QAction) -> None """ Re-emit the action from the page. """ self.__triggeredAction = action # Hide and exit the event loop if necessary. self.hide() self.triggered.emit(action) def __on_textEdited(self, text): # type: (str) -> None self.__suggestPage.setSearchQuery(text) self.__pages.setCurrentPage(self.__suggestPage) self.__pages.setChangeOnHover(not bool(text.strip())) self.__selectFirstIndex() UsageStatistics.set_last_search_query(text) def __selectFirstIndex(self): # type: () -> None view = self.__pages.currentPage().view() model = view.model() index = model.index(0, 0) view.setCurrentIndex(index)
[docs] def triggerSearch(self): # type: () -> None """ Trigger action search. This changes to current page to the 'Suggest' page and sets the keyboard focus to the search line edit. """ self.__pages.setCurrentPage(self.__suggestPage) self.__search.setFocus(Qt.ShortcutFocusReason) # Make sure that the first enabled item is set current. self.__suggestPage.ensureCurrent()
def update_from_settings(self): self.__pages.update_from_settings()
class ItemViewKeyNavigator(QObject): """ A event filter class listening to key press events and responding by moving 'currentItem` on a :class:`QListView`. """ def __init__(self, parent=None, **kwargs): # type: (Optional[QObject], Any) -> None super().__init__(parent, **kwargs) self.__view = None # type: Optional[QAbstractItemView] self.__categoriesEnabled = False def setCategoriesEnabled(self, enabled): self.__categoriesEnabled = enabled def categoriesEnabled(self): return self.__categoriesEnabled def setView(self, view): # type: (Optional[QAbstractItemView]) -> None """ Set the QListView. """ if self.__view != view: self.__view = view def view(self): # type: () -> Optional[QAbstractItemView] """ Return the view """ return self.__view def eventFilter(self, obj, event): etype = event.type() if etype == QEvent.KeyPress: key = event.key() # down if key == Qt.Key_Down: self.moveCurrent(1, 0) return True # up elif key == Qt.Key_Up: self.moveCurrent(-1, 0) return True # enter / return elif key == Qt.Key_Enter or key == Qt.Key_Return: self.activateCurrent() return True # shift + tab elif key == Qt.Key_Backtab: if self.__categoriesEnabled: self.parent().previousPage() return True # tab elif key == Qt.Key_Tab: if self.__categoriesEnabled: self.parent().nextPage() return True return super().eventFilter(obj, event) def moveCurrent(self, rows, columns=0): # type: (int, int) -> None """ Move the current index by rows, columns. """ if self.__view is not None: view = self.__view model = view.model() root = view.rootIndex() curr = view.currentIndex() curr_row, curr_col = curr.row(), curr.column() sign = 1 if rows >= 0 else -1 row = curr_row row_count = model.rowCount(root) for _ in range(row_count): row = (row + sign) % row_count index = model.index(row, 0, root) if index.flags() & Qt.ItemIsEnabled: view.selectionModel().setCurrentIndex( index, QItemSelectionModel.ClearAndSelect ) break # TODO: move by columns def activateCurrent(self): # type: () -> None """ Activate the current index. """ if self.__view is not None: curr = self.__view.currentIndex() if curr.isValid(): self.__view.activated.emit(curr) def ensureCurrent(self): # type: () -> None """ Ensure the view has a current item if one is available. """ if self.__view is not None: model = self.__view.model() curr = self.__view.currentIndex() if not curr.isValid(): root = self.__view.rootIndex() for i in range(model.rowCount(root)): index = model.index(i, 0, root) if index.flags() & Qt.ItemIsEnabled: self.__view.setCurrentIndex(index) break class WindowSizeGrip(QSizeGrip): """ Automatically positioning :class:`QSizeGrip`. The widget automatically maintains its position in the window corner during resize events. """ def __init__(self, parent): super().__init__(parent) self.__corner = Qt.BottomRightCorner self.resize(self.sizeHint()) self.__updatePos() def setCorner(self, corner): """ Set the corner (:class:`Qt.Corner`) where the size grip should position itself. """ if corner not in [Qt.TopLeftCorner, Qt.TopRightCorner, Qt.BottomLeftCorner, Qt.BottomRightCorner]: raise ValueError("Qt.Corner flag expected") if self.__corner != corner: self.__corner = corner self.__updatePos() def corner(self): """ Return the corner where the size grip is positioned. """ return self.__corner def eventFilter(self, obj, event): if obj is self.window(): if event.type() == QEvent.Resize: self.__updatePos() return super().eventFilter(obj, event) def sizeHint(self): self.ensurePolished() sh = super().sizeHint() # Qt5 on macOS forces size grip to be zero size. if sh.width() == 0 and \ QApplication.style().metaObject().className() == "QMacStyle": sh.setWidth(sh.height()) return sh def changeEvent(self, event): # type: (QEvent) -> None super().changeEvent(event) if event.type() in (QEvent.StyleChange, QEvent.MacSizeChange): self.resize(self.sizeHint()) self.__updatePos() super().changeEvent(event) def __updatePos(self): window = self.window() if window is not self.parent(): return corner = self.__corner size = self.size() window_geom = window.geometry() window_size = window_geom.size() if corner in [Qt.TopLeftCorner, Qt.BottomLeftCorner]: x = 0 else: x = window_geom.width() - size.width() if corner in [Qt.TopLeftCorner, Qt.TopRightCorner]: y = 0 else: y = window_size.height() - size.height() self.move(x, y)