# coding:utf-8 from enum import Enum from typing import Union import json from PySide6.QtXml import QDomDocument from PySide6.QtCore import QRectF, Qt, QFile, QObject, QRect from PySide6.QtGui import QIcon, QIconEngine, QColor, QPixmap, QImage, QPainter, QFontDatabase, QFont, QAction, QPainterPath from PySide6.QtSvg import QSvgRenderer from PySide6.QtWidgets import QApplication from .config import isDarkTheme, Theme from .overload import singledispatchmethod class FluentIconEngine(QIconEngine): """ Fluent icon engine """ def __init__(self, icon, reverse=False): """ Parameters ---------- icon: QICon | Icon | FluentIconBase the icon to be drawn reverse: bool whether to reverse the theme of icon """ super().__init__() self.icon = icon self.isThemeReversed = reverse def paint(self, painter, rect, mode, state): painter.save() if mode == QIcon.Disabled: painter.setOpacity(0.5) elif mode == QIcon.Selected: painter.setOpacity(0.7) # change icon color according to the theme icon = self.icon if not self.isThemeReversed: theme = Theme.AUTO else: theme = Theme.LIGHT if isDarkTheme() else Theme.DARK if isinstance(self.icon, Icon): icon = self.icon.fluentIcon.icon(theme) elif isinstance(self.icon, FluentIconBase): icon = self.icon.icon(theme) if rect.x() == 19: rect = rect.adjusted(-1, 0, 0, 0) icon.paint(painter, rect, Qt.AlignCenter, QIcon.Normal, state) painter.restore() class SvgIconEngine(QIconEngine): """ Svg icon engine """ def __init__(self, svg: str): super().__init__() self.svg = svg def paint(self, painter, rect, mode, state): drawSvgIcon(self.svg.encode(), painter, rect) def clone(self) -> QIconEngine: return SvgIconEngine(self.svg) def pixmap(self, size, mode, state): image = QImage(size, QImage.Format_ARGB32) image.fill(Qt.transparent) pixmap = QPixmap.fromImage(image, Qt.NoFormatConversion) painter = QPainter(pixmap) rect = QRect(0, 0, size.width(), size.height()) self.paint(painter, rect, mode, state) return pixmap class FontIconEngine(QIconEngine): """ Font icon engine """ def __init__(self, fontFamily: str, char: str, color, isBold): super().__init__() self.color = color self.char = char self.fontFamily = fontFamily self.isBold = isBold def paint(self, painter, rect, mode, state): font = QFont(self.fontFamily) font.setBold(self.isBold) font.setPixelSize(round(rect.height())) painter.setFont(font) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(self.color) painter.setRenderHints( QPainter.RenderHint.Antialiasing | QPainter.RenderHint.TextAntialiasing) path = QPainterPath() path.addText(rect.x(), rect.y() + rect.height(), font, self.char) painter.drawPath(path) def clone(self) -> QIconEngine: return FontIconEngine(self.fontFamily, self.char, self.color, self.isBold) def pixmap(self, size, mode, state): image = QImage(size, QImage.Format_ARGB32) image.fill(Qt.transparent) pixmap = QPixmap.fromImage(image, Qt.NoFormatConversion) painter = QPainter(pixmap) rect = QRect(0, 0, size.width(), size.height()) self.paint(painter, rect, mode, state) return pixmap def getIconColor(theme=Theme.AUTO, reverse=False): """ get the color of icon based on theme """ if not reverse: lc, dc = "black", "white" else: lc, dc = "white", "black" if theme == Theme.AUTO: color = dc if isDarkTheme() else lc else: color = dc if theme == Theme.DARK else lc return color def drawSvgIcon(icon, painter, rect): """ draw svg icon Parameters ---------- icon: str | bytes | QByteArray the path or code of svg icon painter: QPainter painter rect: QRect | QRectF the rect to render icon """ renderer = QSvgRenderer(icon) renderer.render(painter, QRectF(rect)) def writeSvg(iconPath: str, indexes=None, **attributes): """ write svg with specified attributes Parameters ---------- iconPath: str svg icon path indexes: List[int] the path to be filled **attributes: the attributes of path Returns ------- svg: str svg code """ if not iconPath.lower().endswith('.svg'): return "" f = QFile(iconPath) f.open(QFile.ReadOnly) dom = QDomDocument() dom.setContent(f.readAll()) f.close() # change the color of each path pathNodes = dom.elementsByTagName('path') indexes = range(pathNodes.length()) if not indexes else indexes for i in indexes: element = pathNodes.at(i).toElement() for k, v in attributes.items(): element.setAttribute(k, v) return dom.toString() def drawIcon(icon, painter, rect, state=QIcon.Off, **attributes): """ draw icon Parameters ---------- icon: str | QIcon | FluentIconBaseBase the icon to be drawn painter: QPainter painter rect: QRect | QRectF the rect to render icon **attribute: the attribute of svg icon """ if isinstance(icon, FluentIconBase): icon.render(painter, rect, **attributes) elif isinstance(icon, Icon): icon.fluentIcon.render(painter, rect, **attributes) else: icon = QIcon(icon) icon.paint(painter, QRectF(rect).toRect(), Qt.AlignCenter, state=state) class FluentIconBase: """ Fluent icon base class """ def path(self, theme=Theme.AUTO) -> str: """ get the path of icon Parameters ---------- theme: Theme the theme of icon * `Theme.Light`: black icon * `Theme.DARK`: white icon * `Theme.AUTO`: icon color depends on `config.theme` """ raise NotImplementedError def icon(self, theme=Theme.AUTO, color: QColor = None) -> QIcon: """ create a fluent icon Parameters ---------- theme: Theme the theme of icon * `Theme.Light`: black icon * `Theme.DARK`: white icon * `Theme.AUTO`: icon color depends on `qconfig.theme` color: QColor | Qt.GlobalColor | str icon color, only applicable to svg icon """ path = self.path(theme) if not (path.endswith('.svg') and color): return QIcon(self.path(theme)) color = QColor(color).name() return QIcon(SvgIconEngine(writeSvg(path, fill=color))) def colored(self, lightColor: QColor, darkColor: QColor) -> "ColoredFluentIcon": """ create a colored fluent icon Parameters ---------- lightColor: str | QColor | Qt.GlobalColor icon color in light mode darkColor: str | QColor | Qt.GlobalColor icon color in dark mode """ return ColoredFluentIcon(self, lightColor, darkColor) def qicon(self, reverse=False) -> QIcon: """ convert to QIcon, the theme of icon will be updated synchronously with app Parameters ---------- reverse: bool whether to reverse the theme of icon """ return QIcon(FluentIconEngine(self, reverse)) def render(self, painter, rect, theme=Theme.AUTO, indexes=None, **attributes): """ draw svg icon Parameters ---------- painter: QPainter painter rect: QRect | QRectF the rect to render icon theme: Theme the theme of icon * `Theme.Light`: black icon * `Theme.DARK`: white icon * `Theme.AUTO`: icon color depends on `config.theme` indexes: List[int] the svg path to be modified **attributes: the attributes of modified path """ icon = self.path(theme) if icon.endswith('.svg'): if attributes: icon = writeSvg(icon, indexes, **attributes).encode() drawSvgIcon(icon, painter, rect) else: icon = QIcon(icon) rect = QRectF(rect).toRect() painter.drawPixmap(rect, icon.pixmap(QRectF(rect).toRect().size())) class FluentFontIconBase(FluentIconBase): """ Fluent font icon base class """ _isFontLoaded = False fontId = None fontFamily = None _iconNames = {} def __init__(self, char: str): super().__init__() self.char = char self.lightColor = QColor(0, 0, 0) self.darkColor = QColor(255, 255, 255) self.isBold = False self.loadFont() @classmethod def fromName(cls, name: str): icon = cls("") icon.char = cls._iconNames.get(name, "") return icon def bold(self): self.isBold = True return self def icon(self, theme=Theme.AUTO, color: QColor = None) -> QIcon: if not color: color = self._getIconColor(theme) return QIcon(FontIconEngine(self.fontFamily, self.char, color, self.isBold)) def colored(self, lightColor, darkColor): self.lightColor = QColor(lightColor) self.darkColor = QColor(darkColor) return self def render(self, painter: QPainter, rect, theme=Theme.AUTO, indexes=None, **attributes): color = self._getIconColor(theme) if "fill" in attributes: color = QColor(attributes["fill"]) font = QFont(self.fontFamily) font.setBold(self.isBold) font.setPixelSize(round(rect.height())) painter.setFont(font) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(color) painter.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.TextAntialiasing) path = QPainterPath() path.addText(rect.x(), rect.y() + rect.height(), font, self.char) painter.drawPath(path) def iconNameMapPath(self) -> str: return None def loadFont(self): """ Load icon font """ cls = self.__class__ if cls._isFontLoaded or not QApplication.instance(): return file = QFile(self.path()) if not file.open(QFile.ReadOnly): raise FileNotFoundError(f"Cannot open font file: {self.path()}") data = file.readAll() file.close() cls.fontId = QFontDatabase.addApplicationFontFromData(data) cls.fontFamily = QFontDatabase.applicationFontFamilies(cls.fontId)[0] if self.iconNameMapPath(): self.loadIconNames() def loadIconNames(self): """ Load icon name map """ cls = self.__class__ cls._iconNames.clear() file = QFile(self.iconNameMapPath()) if not file.open(QFile.ReadOnly): raise FileNotFoundError(f"Cannot open font file: {self.iconNameMapPath()}") cls._iconNames = json.loads(str(file.readAll(), encoding='utf-8')) file.close() def _getIconColor(self, theme): if theme == Theme.AUTO: color = self.darkColor if isDarkTheme() else self.lightColor else: color = self.darkColor if theme == Theme.DARK else self.lightColor return color class ColoredFluentIcon(FluentIconBase): """ Colored fluent icon """ def __init__(self, icon: FluentIconBase, lightColor, darkColor): """ Parameters ---------- icon: FluentIconBase the icon to be colored lightColor: str | QColor | Qt.GlobalColor icon color in light mode darkColor: str | QColor | Qt.GlobalColor icon color in dark mode """ super().__init__() self.fluentIcon = icon self.lightColor = QColor(lightColor) self.darkColor = QColor(darkColor) def path(self, theme=Theme.AUTO) -> str: return self.fluentIcon.path(theme) def render(self, painter, rect, theme=Theme.AUTO, indexes=None, **attributes): icon = self.path(theme) if not icon.endswith('.svg'): return self.fluentIcon.render(painter, rect, theme, indexes, attributes) if theme == Theme.AUTO: color = self.darkColor if isDarkTheme() else self.lightColor else: color = self.darkColor if theme == Theme.DARK else self.lightColor attributes.update(fill=color.name()) icon = writeSvg(icon, indexes, **attributes).encode() drawSvgIcon(icon, painter, rect) class FluentIcon(FluentIconBase, Enum): """ Fluent icon """ UP = "Up" ADD = "Add" BUS = "Bus" CAR = "Car" CUT = "Cut" IOT = "IOT" PIN = "Pin" TAG = "Tag" VPN = "VPN" CAFE = "Cafe" CHAT = "Chat" COPY = "Copy" CODE = "Code" DOWN = "Down" EDIT = "Edit" FLAG = "Flag" FONT = "Font" GAME = "Game" HELP = "Help" HIDE = "Hide" HOME = "Home" INFO = "Info" LEAF = "Leaf" LINK = "Link" MAIL = "Mail" MENU = "Menu" MUTE = "Mute" MORE = "More" MOVE = "Move" PLAY = "Play" SAVE = "Save" SEND = "Send" SYNC = "Sync" UNIT = "Unit" VIEW = "View" WIFI = "Wifi" ZOOM = "Zoom" ALBUM = "Album" BRUSH = "Brush" BROOM = "Broom" CLOSE = "Close" CLOUD = "Cloud" EMBED = "Embed" GLOBE = "Globe" HEART = "Heart" LABEL = "Label" MEDIA = "Media" MOVIE = "Movie" MUSIC = "Music" ROBOT = "Robot" PAUSE = "Pause" PASTE = "Paste" PHOTO = "Photo" PHONE = "Phone" PRINT = "Print" SHARE = "Share" TILES = "Tiles" UNPIN = "Unpin" VIDEO = "Video" TRAIN = "Train" ADD_TO ="AddTo" ACCEPT = "Accept" CAMERA = "Camera" CANCEL = "Cancel" DELETE = "Delete" FOLDER = "Folder" FILTER = "Filter" MARKET = "Market" SCROLL = "Scroll" LAYOUT = "Layout" GITHUB = "GitHub" UPDATE = "Update" REMOVE = "Remove" RETURN = "Return" PEOPLE = "People" QRCODE = "QRCode" RINGER = "Ringer" ROTATE = "Rotate" SEARCH = "Search" VOLUME = "Volume" FRIGID = "Frigid" SAVE_AS = "SaveAs" ZOOM_IN = "ZoomIn" CONNECT ="Connect" HISTORY = "History" SETTING = "Setting" PALETTE = "Palette" MESSAGE = "Message" FIT_PAGE = "FitPage" ZOOM_OUT = "ZoomOut" AIRPLANE = "Airplane" ASTERISK = "Asterisk" CALORIES = "Calories" CALENDAR = "Calendar" FEEDBACK = "Feedback" LIBRARY = "BookShelf" MINIMIZE = "Minimize" CHECKBOX = "CheckBox" DOCUMENT = "Document" LANGUAGE = "Language" DOWNLOAD = "Download" QUESTION = "Question" SPEAKERS = "Speakers" DATE_TIME = "DateTime" FONT_SIZE = "FontSize" HOME_FILL = "HomeFill" PAGE_LEFT = "PageLeft" SAVE_COPY = "SaveCopy" SEND_FILL = "SendFill" SKIP_BACK = "SkipBack" SPEED_OFF = "SpeedOff" ALIGNMENT = "Alignment" BLUETOOTH = "Bluetooth" COMPLETED = "Completed" CONSTRACT = "Constract" HEADPHONE = "Headphone" MEGAPHONE = "Megaphone" PROJECTOR = "Projector" EDUCATION = "Education" LEFT_ARROW = "LeftArrow" ERASE_TOOL = "EraseTool" PAGE_RIGHT = "PageRight" PLAY_SOLID = "PlaySolid" BOOK_SHELF = "BookShelf" HIGHTLIGHT = "Highlight" FOLDER_ADD = "FolderAdd" PAUSE_BOLD = "PauseBold" PENCIL_INK = "PencilInk" PIE_SINGLE = "PieSingle" QUICK_NOTE = "QuickNote" SPEED_HIGH = "SpeedHigh" STOP_WATCH = "StopWatch" ZIP_FOLDER = "ZipFolder" BASKETBALL = "Basketball" BRIGHTNESS = "Brightness" DICTIONARY = "Dictionary" MICROPHONE = "Microphone" ARROW_DOWN = "ChevronDown" FULL_SCREEN = "FullScreen" MIX_VOLUMES = "MixVolumes" REMOVE_FROM = "RemoveFrom" RIGHT_ARROW = "RightArrow" QUIET_HOURS ="QuietHours" FINGERPRINT = "Fingerprint" APPLICATION = "Application" CERTIFICATE = "Certificate" TRANSPARENT = "Transparent" IMAGE_EXPORT = "ImageExport" SPEED_MEDIUM = "SpeedMedium" LIBRARY_FILL = "LibraryFill" MUSIC_FOLDER = "MusicFolder" POWER_BUTTON = "PowerButton" SKIP_FORWARD = "SkipForward" CARE_UP_SOLID = "CareUpSolid" ACCEPT_MEDIUM = "AcceptMedium" CANCEL_MEDIUM = "CancelMedium" CHEVRON_RIGHT = "ChevronRight" CLIPPING_TOOL = "ClippingTool" SEARCH_MIRROR = "SearchMirror" SHOPPING_CART = "ShoppingCart" FONT_INCREASE = "FontIncrease" BACK_TO_WINDOW = "BackToWindow" COMMAND_PROMPT = "CommandPrompt" CLOUD_DOWNLOAD = "CloudDownload" DICTIONARY_ADD = "DictionaryAdd" CARE_DOWN_SOLID = "CareDownSolid" CARE_LEFT_SOLID = "CareLeftSolid" CLEAR_SELECTION = "ClearSelection" DEVELOPER_TOOLS = "DeveloperTools" BACKGROUND_FILL = "BackgroundColor" CARE_RIGHT_SOLID = "CareRightSolid" CHEVRON_DOWN_MED = "ChevronDownMed" CHEVRON_RIGHT_MED = "ChevronRightMed" EMOJI_TAB_SYMBOLS = "EmojiTabSymbols" EXPRESSIVE_INPUT_ENTRY = "ExpressiveInputEntry" def path(self, theme=Theme.AUTO): return f':/qfluentwidgets/images/icons/{self.value}_{getIconColor(theme)}.svg' class Icon(QIcon): def __init__(self, fluentIcon: FluentIcon): super().__init__(fluentIcon.path()) self.fluentIcon = fluentIcon def toQIcon(icon: Union[QIcon, FluentIconBase, str]) -> QIcon: """ convet `icon` to `QIcon` """ if isinstance(icon, str): return QIcon(icon) if isinstance(icon, FluentIconBase): return icon.icon() return icon class Action(QAction): """ Fluent action Constructors ------------ * Action(`parent`: QWidget = None, `**kwargs`) * Action(`text`: str, `parent`: QWidget = None, `**kwargs`) * Action(`icon`: QIcon | FluentIconBase, `parent`: QWidget = None, `**kwargs`) """ @singledispatchmethod def __init__(self, parent: QObject = None, **kwargs): super().__init__(parent, **kwargs) self.fluentIcon = None @__init__.register def _(self, text: str, parent: QObject = None, **kwargs): super().__init__(text, parent, **kwargs) self.fluentIcon = None @__init__.register def _(self, icon: QIcon, text: str, parent: QObject = None, **kwargs): super().__init__(icon, text, parent, **kwargs) self.fluentIcon = None @__init__.register def _(self, icon: FluentIconBase, text: str, parent: QObject = None, **kwargs): super().__init__(icon.icon(), text, parent, **kwargs) self.fluentIcon = icon def icon(self) -> QIcon: if self.fluentIcon: return Icon(self.fluentIcon) return super().icon() def setIcon(self, icon: Union[FluentIconBase, QIcon]): if isinstance(icon, FluentIconBase): self.fluentIcon = icon icon = icon.icon() super().setIcon(icon)