# coding:utf-8 import math from typing import Dict, List from PySide6.QtCore import Qt, Signal, QRectF, Property, QPoint, QEvent from PySide6.QtGui import QPainter, QFont, QHoverEvent, QAction from PySide6.QtWidgets import QWidget, QApplication from ...common.font import setFont from ...common.icon import FluentIcon from ...common.style_sheet import isDarkTheme from ...components.widgets.menu import RoundMenu, MenuAnimationType class BreadcrumbWidget(QWidget): """ Bread crumb widget """ clicked = Signal() def __init__(self, parent=None): super().__init__(parent=parent) self.isHover = False self.isPressed = False def mousePressEvent(self, e): self.isPressed = True self.update() def mouseReleaseEvent(self, e): self.isPressed = False self.update() self.clicked.emit() def enterEvent(self, e): self.isHover = True self.update() def leaveEvent(self, e): self.isHover = False self.update() class ElideButton(BreadcrumbWidget): """ Elide button """ def __init__(self, parent=None): super().__init__(parent) self.setFixedSize(16, 16) def paintEvent(self, e): painter = QPainter(self) painter.setRenderHints(QPainter.Antialiasing) painter.setPen(Qt.NoPen) if self.isPressed: painter.setOpacity(0.5) elif not self.isHover: painter.setOpacity(0.61) FluentIcon.MORE.render(painter, self.rect()) def clearState(self): self.setAttribute(Qt.WA_UnderMouse, False) self.isHover = False e = QHoverEvent(QEvent.HoverLeave, QPoint(-1, -1), QPoint()) QApplication.sendEvent(self, e) class BreadcrumbItem(BreadcrumbWidget): """ Breadcrumb item """ def __init__(self, routeKey: str, text: str, index: int, parent=None): super().__init__(parent=parent) self.text = text self.routeKey = routeKey self.isHover = False self.isPressed = False self.isSelected = False self.index = index self.spacing = 5 def setText(self, text: str): self.text = text rect = self.fontMetrics().boundingRect(text) w = rect.width() + math.ceil(self.font().pixelSize() / 10) if not self.isRoot(): w += self.spacing * 2 self.setFixedWidth(w) self.setFixedHeight(rect.height()) self.update() def isRoot(self): return self.index == 0 def setSelected(self, isSelected: bool): self.isSelected = isSelected self.update() def setFont(self, font: QFont): super().setFont(font) self.setText(self.text) def setSpacing(self, spacing: int): self.spacing = spacing self.setText(self.text) def paintEvent(self, e): painter = QPainter(self) painter.setRenderHints(QPainter.TextAntialiasing | QPainter.Antialiasing) painter.setPen(Qt.NoPen) # draw seperator sw = self.spacing * 2 if not self.isRoot(): iw = self.font().pixelSize() / 14 * 8 rect = QRectF((sw - iw) / 2, (self.height() - iw) / 2 + 1, iw, iw) painter.setOpacity(0.61) FluentIcon.CHEVRON_RIGHT_MED.render(painter, rect) # draw text if self.isPressed: alpha = 0.54 if isDarkTheme() else 0.45 painter.setOpacity(1 if self.isSelected else alpha) elif self.isSelected or self.isHover: painter.setOpacity(1) else: painter.setOpacity(0.79 if isDarkTheme() else 0.61) painter.setFont(self.font()) painter.setPen(Qt.white if isDarkTheme() else Qt.black) if self.isRoot(): rect = self.rect() else: rect = QRectF(sw, 0, self.width() - sw, self.height()) painter.drawText(rect, Qt.AlignVCenter | Qt.AlignLeft, self.text) class BreadcrumbBar(QWidget): """ Breadcrumb bar """ currentItemChanged = Signal(str) currentIndexChanged = Signal(int) def __init__(self, parent=None): super().__init__(parent=parent) self.itemMap = {} # type: Dict[BreadcrumbItem] self.items = [] # type: List[BreadcrumbItem] self.hiddenItems = [] # type: List[BreadcrumbItem] self._spacing = 10 self._currentIndex = -1 self.elideButton = ElideButton(self) setFont(self, 14) self.setAttribute(Qt.WA_TranslucentBackground) self.elideButton.hide() self.elideButton.clicked.connect(self._showHiddenItemsMenu) def addItem(self, routeKey: str, text: str): """ add item Parameters ---------- routeKey: str unique key of item text: str the text of item """ if routeKey in self.itemMap: return item = BreadcrumbItem(routeKey, text, len(self.items), self) item.setFont(self.font()) item.setSpacing(self.spacing) item.clicked.connect(lambda: self.setCurrentItem(routeKey)) self.itemMap[routeKey] = item self.items.append(item) self.setFixedHeight(max(i.height() for i in self.items)) self.setCurrentItem(routeKey) self.updateGeometry() def setCurrentIndex(self, index: int): if not 0 <= index < len(self.items) or index == self.currentIndex(): return if 0<= self.currentIndex() < len(self.items): self.currentItem().setSelected(False) self._currentIndex = index self.currentItem().setSelected(True) # remove trailing items for item in self.items[-1:index:-1]: item = self.items.pop() self.itemMap.pop(item.routeKey) item.deleteLater() self.updateGeometry() self.currentIndexChanged.emit(index) self.currentItemChanged.emit(self.currentItem().routeKey) def setCurrentItem(self, routeKey: str): if routeKey not in self.itemMap: return self.setCurrentIndex(self.items.index(self.itemMap[routeKey])) def setItemText(self, routeKey: str, text: str): item = self.item(routeKey) if item: item.setText(text) def item(self, routeKey: str) -> BreadcrumbItem: return self.itemMap.get(routeKey, None) def itemAt(self, index: int): if 0 <= index < len(self.items): return self.items[index] return None def currentIndex(self): return self._currentIndex def currentItem(self) -> BreadcrumbItem: if self.currentIndex() >= 0: return self.items[self.currentIndex()] return None def resizeEvent(self, e): self.updateGeometry() def clear(self): """ clear all items """ while self.items: item = self.items.pop() self.itemMap.pop(item.routeKey) item.deleteLater() self.elideButton.hide() self._currentIndex = -1 def popItem(self): """ pop trailing item """ if not self.items: return if self.count() >= 2: self.setCurrentIndex(self.currentIndex() - 1) else: self.clear() def count(self): """ Returns the number of items """ return len(self.items) def updateGeometry(self): if not self.items: return x = 0 self.elideButton.hide() self.hiddenItems = self.items[:-1].copy() if not self.isElideVisible(): visibleItems = self.items self.hiddenItems.clear() else: visibleItems = [self.elideButton, self.items[-1]] w = sum(i.width() for i in visibleItems) for item in self.items[-2::-1]: w += item.width() if w > self.width(): break visibleItems.insert(1, item) self.hiddenItems.remove(item) for item in self.hiddenItems: item.hide() for item in visibleItems: item.move(x, (self.height() - item.height()) // 2) item.show() x += item.width() def isElideVisible(self): w = sum(i.width() for i in self.items) return w > self.width() def setFont(self, font: QFont): super().setFont(font) s = int(font.pixelSize() / 14 * 16) self.elideButton.setFixedSize(s, s) for item in self.items: item.setFont(font) def _showHiddenItemsMenu(self): self.elideButton.clearState() menu = RoundMenu(parent=self) menu.setItemHeight(32) for item in self.hiddenItems: menu.addAction( QAction(item.text, menu, triggered=lambda checked=True, i=item: self.setCurrentItem(i.routeKey))) # determine the animation type by choosing the maximum height of view x = -menu.layout().contentsMargins().left() pd = self.mapToGlobal(QPoint(x, self.height())) hd = menu.view.heightForAnimation(pd, MenuAnimationType.DROP_DOWN) pu = self.mapToGlobal(QPoint(x, 0)) hu = menu.view.heightForAnimation(pu, MenuAnimationType.PULL_UP) if hd >= hu: menu.view.adjustSize(pd, MenuAnimationType.DROP_DOWN) menu.exec(pd, aniType=MenuAnimationType.DROP_DOWN) else: menu.view.adjustSize(pu, MenuAnimationType.PULL_UP) menu.exec(pu, aniType=MenuAnimationType.PULL_UP) def getSpacing(self): return self._spacing def setSpacing(self, spacing: int): if spacing == self._spacing: return self._spacing = spacing for item in self.items: item.setSpacing(spacing) spacing = Property(int, getSpacing, setSpacing)