"""
==========
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, load_styled_svg_icon

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


class MenuPage(ToolTree):
    """
    A menu page in a :class:`QuickMenu` widget, showing a list of actions.
    Shown actions can be disabled by setting a filtering function using the
    :func:`setFilterFunc`.

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

        self.__title = title
        self.__icon = QIcon(icon)
        self.__sizeHint = None  # type: Optional[QSize]

        self.view().setItemDelegate(_MenuItemDelegate(self.view()))
        self.view().viewport().setMouseTracking(True)
        self.view().viewport().installEventFilter(self)

        # Make sure the initial model is wrapped in a ItemDisableFilter.
        self.setModel(self.model())

    def setTitle(self, title):
        # type: (str) -> None
        """
        Set the title of the page.
        """
        if self.__title != title:
            self.__title = title
            self.update()

    def title(self):
        # type: () -> str
        """
        Return the title of this page.
        """
        return self.__title

    title_ = Property(str, fget=title, fset=setTitle, doc="Title of the page.")

    def setIcon(self, icon):  # type: (QIcon) -> None
        """
        Set icon for this menu page.
        """
        if self.__icon != icon:
            self.__icon = icon
            self.update()

    def icon(self):  # type: () -> QIcon
        """
        Return the icon of this menu page.
        """
        return QIcon(self.__icon)

    icon_ = Property(QIcon, fget=icon, fset=setIcon,
                     doc="Page icon")

    def setFilterFunc(self, func):
        # type: (Optional[Callable[[QModelIndex], bool]]) -> None
        """
        Set the filtering function. `func` should a function taking a single
        :class:`QModelIndex` argument and returning True if the item at index
        should be disabled and False otherwise. To disable filtering `func` can
        be set to ``None``.

        """
        proxyModel = self.view().model()
        proxyModel.setFilterFunc(func)

    def setModel(self, model):
        # type: (QAbstractItemModel) -> None
        """
        Reimplemented from :func:`ToolTree.setModel`.
        """
        proxyModel = ItemDisableFilter(self)
        proxyModel.setSourceModel(model)
        super().setModel(proxyModel)

        self.__invalidateSizeHint()

    def setRootIndex(self, index):
        # type: (QModelIndex) -> None
        """
        Reimplemented from :func:`ToolTree.setRootIndex`
        """
        proxyModel = self.view().model()
        mappedIndex = proxyModel.mapFromSource(index)
        super().setRootIndex(mappedIndex)

        self.__invalidateSizeHint()

    def rootIndex(self):
        # type: () -> QModelIndex
        """
        Reimplemented from :func:`ToolTree.rootIndex`
        """
        proxyModel = self.view().model()
        return proxyModel.mapToSource(super().rootIndex())

    def sizeHint(self):
        # type: () -> QSize
        """
        Reimplemented from :func:`QWidget.sizeHint`.
        """
        if self.__sizeHint is None:
            view = self.view()
            model = view.model()

            # This will not work for nested items (tree).
            count = model.rowCount(view.rootIndex())

            # 'sizeHintForColumn' is the reason for size hint caching
            # since it must traverse all items in the column.
            width = view.sizeHintForColumn(0)

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

            # add scrollbar width
            scroll = self.view().verticalScrollBar()
            isTransient = scroll.style().styleHint(QStyle.SH_ScrollBar_Transient, widget=scroll)
            if not isTransient:
                width += scroll.style().pixelMetric(QStyle.PM_ScrollBarExtent, widget=scroll)

            self.__sizeHint = QSize(width, height)

        return self.__sizeHint

    def __invalidateSizeHint(self):  # type: () -> None
        self.__sizeHint = None
        self.updateGeometry()

    def eventFilter(self, recv: QObject, event: QEvent) -> bool:
        if event.type() == QEvent.MouseMove and recv is self.view().viewport():
            mouseevent = typing.cast(QMouseEvent, event)
            view = self.view()
            index = view.indexAt(mouseevent.pos())
            if index.isValid() and index.flags() & Qt.ItemIsEnabled:
                view.setCurrentIndex(index)
        return super().eventFilter(recv, event)


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 = QIcon(load_styled_svg_icon("Search.svg"))
        action = QAction(icon, self.tr("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`,
# ...)


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]

    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_()

    def sizeGripEnabled(self):
        # type: () -> bool
        """
        Is the size grip enabled.
        """
        return bool(self.__grip)

    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

    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)

    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)

    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)

    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)

    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)

    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)

    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()

    def setCurrentPage(self, page):
        # type: (MenuPage) -> None
        """
        Set the current shown page to `page`.
        """
        self.__pages.setCurrentPage(page)

    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)

    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)
