diff --git a/examples/navigation/navigation_user_card/demo.py b/examples/navigation/navigation_user_card/demo.py new file mode 100644 index 000000000..3180fae73 --- /dev/null +++ b/examples/navigation/navigation_user_card/demo.py @@ -0,0 +1,99 @@ +# coding:utf-8 +import sys +from pathlib import Path + +#sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QApplication, QFrame, QHBoxLayout +from qfluentwidgets import (NavigationItemPosition, MessageBox, FluentWindow, SubtitleLabel, setFont) +from qfluentwidgets import FluentIcon as FIF + + +class Widget(QFrame): + + def __init__(self, text: str, parent=None): + super().__init__(parent=parent) + self.label = SubtitleLabel(text, self) + self.hBoxLayout = QHBoxLayout(self) + + setFont(self.label, 24) + self.label.setAlignment(Qt.AlignCenter) + self.hBoxLayout.addWidget(self.label, 1, Qt.AlignCenter) + self.setObjectName(text.replace(' ', '-')) + + +class Window(FluentWindow): + + def __init__(self): + super().__init__() + + # create sub interface + self.homeInterface = Widget('Home Interface', self) + self.musicInterface = Widget('Music Interface', self) + self.videoInterface = Widget('Video Interface', self) + self.settingInterface = Widget('Setting Interface', self) + + self.initNavigation() + self.initWindow() + + def initNavigation(self): + # add user card with custom parameters + self.userCard = self.navigationInterface.addUserCard( + routeKey='userCard', + avatar='resource/shoko.png', + title='zhiyiYo', + subtitle='shokokawaii@outlook.com', + onClick=self.showMessageBox, + position=NavigationItemPosition.TOP, + aboveMenuButton=False # place below the expand/collapse button + ) + + # customize user card (optional) + # self.userCard.setTitleFontSize(15) + # self.userCard.setSubtitleFontSize(11) + # self.userCard.setAnimationDuration(300) + + # placement: set aboveMenuButton=True to place card above expand/collapse button + # default: aboveMenuButton=False (card placed below menu button) + + # add navigation items + self.addSubInterface(self.homeInterface, FIF.HOME, 'Home') + self.addSubInterface(self.musicInterface, FIF.MUSIC, 'Music library') + + self.navigationInterface.addSeparator() + + self.addSubInterface(self.videoInterface, FIF.VIDEO, 'Video library') + self.addSubInterface(self.settingInterface, FIF.SETTING, 'Settings', NavigationItemPosition.BOTTOM) + + def initWindow(self): + self.resize(900, 700) + self.setWindowIcon(QIcon('resource/logo.png')) + self.setWindowTitle('Navigation User Card') + + desktop = QApplication.desktop().availableGeometry() + w, h = desktop.width(), desktop.height() + self.move(w//2 - self.width()//2, h//2 - self.height()//2) + + def showMessageBox(self): + w = MessageBox( + 'User Card', + 'This is a navigation user card that displays avatar, title and subtitle.\n\n' + 'Placement:\n' + '• aboveMenuButton=True: Place above expand/collapse button\n' + '• aboveMenuButton=False: Place below menu button (default)', + self + ) + w.exec_() + + +if __name__ == '__main__': + QApplication.setHighDpiScaleFactorRoundingPolicy(Qt.HighDpiScaleFactorRoundingPolicy.PassThrough) + QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) + QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps) + + app = QApplication(sys.argv) + w = Window() + w.show() + app.exec_() diff --git a/examples/navigation/navigation_user_card/resource/dark/demo.qss b/examples/navigation/navigation_user_card/resource/dark/demo.qss new file mode 100644 index 000000000..6ae4cff24 --- /dev/null +++ b/examples/navigation/navigation_user_card/resource/dark/demo.qss @@ -0,0 +1,50 @@ +Widget > QLabel { + font: 24px 'Segoe UI', 'Microsoft YaHei'; +} + +Widget { + border: 1px solid rgb(29, 29, 29); + border-right: none; + border-bottom: none; + border-top-left-radius: 10px; + background-color: rgb(39, 39, 39); +} + +Window { + background-color: rgb(32, 32, 32); +} + +StandardTitleBar { + background-color: rgb(32, 32, 32); +} + +StandardTitleBar > QLabel, +Widget > QLabel { + color: white; +} + + +MinimizeButton { + qproperty-normalColor: white; + qproperty-normalBackgroundColor: transparent; + qproperty-hoverColor: white; + qproperty-hoverBackgroundColor: rgba(255, 255, 255, 26); + qproperty-pressedColor: white; + qproperty-pressedBackgroundColor: rgba(255, 255, 255, 51) +} + + +MaximizeButton { + qproperty-normalColor: white; + qproperty-normalBackgroundColor: transparent; + qproperty-hoverColor: white; + qproperty-hoverBackgroundColor: rgba(255, 255, 255, 26); + qproperty-pressedColor: white; + qproperty-pressedBackgroundColor: rgba(255, 255, 255, 51) +} + +CloseButton { + qproperty-normalColor: white; + qproperty-normalBackgroundColor: transparent; +} + diff --git a/examples/navigation/navigation_user_card/resource/light/demo.qss b/examples/navigation/navigation_user_card/resource/light/demo.qss new file mode 100644 index 000000000..84f5cab7d --- /dev/null +++ b/examples/navigation/navigation_user_card/resource/light/demo.qss @@ -0,0 +1,16 @@ +Widget > QLabel { + font: 24px 'Segoe UI', 'Microsoft YaHei'; +} + +Widget { + border: 1px solid rgb(229, 229, 229); + border-right: none; + border-bottom: none; + border-top-left-radius: 10px; + background-color: rgb(249, 249, 249); +} + +Window { + background-color: rgb(243, 243, 243); +} + diff --git a/examples/navigation/navigation_user_card/resource/logo.png b/examples/navigation/navigation_user_card/resource/logo.png new file mode 100644 index 000000000..47f675176 Binary files /dev/null and b/examples/navigation/navigation_user_card/resource/logo.png differ diff --git a/examples/navigation/navigation_user_card/resource/shoko.png b/examples/navigation/navigation_user_card/resource/shoko.png new file mode 100644 index 000000000..7d8894bb8 Binary files /dev/null and b/examples/navigation/navigation_user_card/resource/shoko.png differ diff --git a/qfluentwidgets/components/navigation/__init__.py b/qfluentwidgets/components/navigation/__init__.py index edbdb634c..ce83cd200 100644 --- a/qfluentwidgets/components/navigation/__init__.py +++ b/qfluentwidgets/components/navigation/__init__.py @@ -1,5 +1,6 @@ from .navigation_widget import (NavigationWidget, NavigationPushButton, NavigationSeparator, NavigationToolButton, NavigationTreeWidget, NavigationTreeWidgetBase, NavigationAvatarWidget, NavigationItemHeader) +from .navigation_user_card import NavigationUserCard from .navigation_panel import NavigationPanel, NavigationItemPosition, NavigationDisplayMode from .navigation_interface import NavigationInterface from .navigation_bar import NavigationBarPushButton, NavigationBar diff --git a/qfluentwidgets/components/navigation/navigation_interface.py b/qfluentwidgets/components/navigation/navigation_interface.py index a031c77dc..eab779359 100644 --- a/qfluentwidgets/components/navigation/navigation_interface.py +++ b/qfluentwidgets/components/navigation/navigation_interface.py @@ -2,11 +2,12 @@ from typing import Union from PyQt5.QtCore import Qt, QEvent, pyqtSignal -from PyQt5.QtGui import QResizeEvent, QIcon +from PyQt5.QtGui import QResizeEvent, QIcon, QPixmap from PyQt5.QtWidgets import QWidget from .navigation_panel import NavigationPanel, NavigationItemPosition, NavigationWidget, NavigationDisplayMode from .navigation_widget import NavigationTreeWidget +from .navigation_user_card import NavigationUserCard from ...common.style_sheet import FluentStyleSheet from ...common.icon import FluentIconBase @@ -220,6 +221,68 @@ def insertItemHeader(self, index: int, text: str, position=NavigationItemPositio """ return self.panel.insertItemHeader(index, text, position) + def addUserCard(self, routeKey: str, avatar: Union[str, QIcon, FluentIconBase] = None, + title: str = '', subtitle: str = '', onClick=None, + position=NavigationItemPosition.TOP, aboveMenuButton: bool = False): + """ add user card to navigation panel + + Parameters + ---------- + routeKey: str + the unique name of user card + + avatar: str | QIcon | FluentIconBase + avatar image or icon + + title: str + user name or title text + + subtitle: str + subtitle text (e.g., email, status) + + onClick: callable + the slot connected to card clicked signal + + position: NavigationItemPosition + where the card is added + + aboveMenuButton: bool + whether to place the card above the menu button (expand/collapse button) + + Returns + ------- + NavigationUserCard + created user card widget + """ + card = NavigationUserCard(self) + + if avatar: + if isinstance(avatar, FluentIconBase): + card.setAvatarIcon(avatar) + else: + card.setAvatar(avatar) + + card.setTitle(title) + card.setSubtitle(subtitle) + + # calculate insert index if placing above menu button + index = -1 + if aboveMenuButton and position == NavigationItemPosition.TOP: + # find menu button index in top layout + layout = self.panel.topLayout + for i in range(layout.count()): + item = layout.itemAt(i) + if item and item.widget() == self.panel.menuButton: + index = i + break + + if index >= 0: + self.panel.insertWidget(index, routeKey, card, onClick, position) + else: + self.addWidget(routeKey, card, onClick, position) + + return card + def insertSeparator(self, index: int, position=NavigationItemPosition.TOP): """ add separator diff --git a/qfluentwidgets/components/navigation/navigation_user_card.py b/qfluentwidgets/components/navigation/navigation_user_card.py new file mode 100644 index 000000000..80d5e4d79 --- /dev/null +++ b/qfluentwidgets/components/navigation/navigation_user_card.py @@ -0,0 +1,185 @@ +# coding:utf-8 +from typing import Union + +from PyQt5.QtCore import Qt, QRectF, QPropertyAnimation, pyqtProperty, QEasingCurve, QParallelAnimationGroup +from PyQt5.QtGui import QPainter, QColor, QFont + +from ...common.icon import FluentIcon as FIF, toQIcon +from ...common.font import getFont +from .navigation_widget import NavigationAvatarWidget + + +class NavigationUserCard(NavigationAvatarWidget): + """ Navigation user card widget """ + + def __init__(self, parent=None): + super().__init__(name="", parent=parent) + + # text properties + self._title = "" + self._subtitle = "" + self._titleSize = 14 + self._subtitleSize = 12 + self._subtitleColor = None # type: QColor + + # animation properties + self._textOpacity = 0.0 + self._animationDuration = 250 + self._animationGroup = QParallelAnimationGroup(self) + + # avatar size animation + self._radiusAni = QPropertyAnimation(self.avatar, b"radius", self) + self._radiusAni.setDuration(self._animationDuration) + self._radiusAni.setEasingCurve(QEasingCurve.OutCubic) + self._radiusAni.valueChanged.connect(self._updateAvatarPosition) + + # text opacity animation + self._opacityAni = QPropertyAnimation(self, b"textOpacity", self) + self._opacityAni.setDuration(int(self._animationDuration * 0.8)) + self._opacityAni.setEasingCurve(QEasingCurve.InOutQuad) + + self._animationGroup.addAnimation(self._radiusAni) + self._animationGroup.addAnimation(self._opacityAni) + + # initial size + self.setFixedSize(40, 36) + + def setAvatarIcon(self, icon: FIF): + """ set avatar icon when no image is set """ + self.avatar.setImage(toQIcon(icon).pixmap(64, 64)) + self.update() + + def setAvatarBackgroundColor(self, light: QColor, dark: QColor): + """ set avatar background color in light/dark theme mode """ + self.avatar.setBackgroundColor(light, dark) + self.update() + + def title(self): + """ get user card title """ + return self._title + + def setTitle(self, title: str): + """ set user card title """ + self._title = title + self.setName(title) + self.update() + + def subtitle(self): + """ get user card subtitle """ + return self._subtitle + + def setSubtitle(self, subtitle: str): + """ set user card subtitle """ + self._subtitle = subtitle + self.update() + + def setTitleFontSize(self, size: int): + """ set title font size """ + self._titleSize = size + self.update() + + def setSubtitleFontSize(self, size: int): + """ set subtitle font size """ + self._subtitleSize = size + self.update() + + def setAnimationDuration(self, duration: int): + """ set animation duration in milliseconds """ + self._animationDuration = duration + self._radiusAni.setDuration(duration) + self._opacityAni.setDuration(int(duration * 0.8)) + + def setCompacted(self, isCompacted: bool): + """ set whether the widget is compacted """ + if isCompacted == self.isCompacted: + return + + self.isCompacted = isCompacted + + if isCompacted: + # compact mode: 24x24 avatar like NavigationAvatarWidget + self.setFixedSize(40, 36) + self._radiusAni.setStartValue(self.avatar.radius) + self._radiusAni.setEndValue(12) # 24px diameter + self._opacityAni.setStartValue(self._textOpacity) + self._opacityAni.setEndValue(0.0) + else: + # expanded mode: large avatar with text + self.setFixedSize(self.EXPAND_WIDTH, 80) + self._radiusAni.setStartValue(self.avatar.radius) + self._radiusAni.setEndValue(32) # 64px diameter + self._opacityAni.setStartValue(self._textOpacity) + self._opacityAni.setEndValue(1.0) + + self._animationGroup.start() + + def paintEvent(self, e): + painter = QPainter(self) + painter.setRenderHints( + QPainter.SmoothPixmapTransform | QPainter.Antialiasing | QPainter.TextAntialiasing + ) + + if self.isPressed: + painter.setOpacity(0.7) + + # draw hover background + self._drawBackground(painter) + + # draw text in expanded mode + if not self.isCompacted and self._textOpacity > 0: + self._drawText(painter) + + def _drawText(self, painter: QPainter): + """ draw title and subtitle """ + textX = 16 + int(self.avatar.radius * 2) + 12 + textWidth = self.width() - textX - 16 + + # draw title + painter.setFont(getFont(self._titleSize, QFont.Bold)) + c = self.textColor() + c.setAlpha(int(255 * self._textOpacity)) + painter.setPen(c) + + titleY = self.height() // 2 - 2 + painter.drawText(QRectF(textX, 0, textWidth, titleY), + Qt.AlignLeft | Qt.AlignBottom, + self._title) + + # draw subtitle with semi-transparent color + if self._subtitle: + painter.setFont(getFont(self._subtitleSize)) + + c = self.subtitleColor or self.textColor() + c.setAlpha(int(150 * self._textOpacity)) + painter.setPen(c) + + subtitleY = self.height() // 2 + 2 + painter.drawText(QRectF(textX, subtitleY, textWidth, self.height() - subtitleY), + Qt.AlignLeft | Qt.AlignTop, + self._subtitle) + + def _updateAvatarPosition(self): + """ update avatar position based on current size """ + if self.isCompacted: + self.avatar.move(8, 6) + else: + self.avatar.move(16, (self.height() - self.avatar.height()) // 2) + + # properties + @pyqtProperty(float) + def textOpacity(self): + return self._textOpacity + + @textOpacity.setter + def textOpacity(self, value: float): + self._textOpacity = value + self.update() + + @pyqtProperty(QColor) + def subtitleColor(self): + return self._subtitleColor + + @subtitleColor.setter + def subtitleColor(self, color: QColor): + self._subtitleColor = color + self.update() diff --git a/qfluentwidgets/components/navigation/navigation_widget.py b/qfluentwidgets/components/navigation/navigation_widget.py index c18ce18f2..28f70dc89 100644 --- a/qfluentwidgets/components/navigation/navigation_widget.py +++ b/qfluentwidgets/components/navigation/navigation_widget.py @@ -679,16 +679,20 @@ def paintEvent(self, e): painter.setOpacity(0.7) # draw background - if self.isEnter: - c = 255 if isDarkTheme() else 0 - painter.setBrush(QColor(c, c, c, 10)) - painter.drawRoundedRect(self.rect(), 5, 5) + self._drawBackground(painter) if not self.isCompacted: painter.setPen(self.textColor()) painter.setFont(self.font()) painter.drawText(QRect(44, 0, 255, 36), Qt.AlignVCenter, self.name) + def _drawBackground(self, painter): + if self.isEnter: + c = 255 if isDarkTheme() else 0 + painter.setBrush(QColor(c, c, c, 10)) + painter.setPen(Qt.NoPen) + painter.drawRoundedRect(self.rect(), 5, 5) + @InfoBadgeManager.register(InfoBadgePosition.NAVIGATION_ITEM) class NavigationItemInfoBadgeManager(InfoBadgeManager):