"""
==========
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
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 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)