Files

704 lines
19 KiB
Python
Raw Permalink Normal View History

2025-08-14 18:45:16 +08:00
# 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)