initial fluent-widgets ui
This commit is contained in:
12
qfluentwidgets/common/__init__.py
Normal file
12
qfluentwidgets/common/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
from .config import *
|
||||
from .font import setFont, getFont
|
||||
from .auto_wrap import TextWrap
|
||||
from .icon import Action, Icon, getIconColor, drawSvgIcon, FluentIcon, drawIcon, FluentIconBase, writeSvg, FluentFontIconBase
|
||||
from .style_sheet import (setStyleSheet, getStyleSheet, setTheme, ThemeColor, themeColor,
|
||||
setThemeColor, applyThemeColor, FluentStyleSheet, StyleSheetBase,
|
||||
StyleSheetFile, StyleSheetCompose, CustomStyleSheet, toggleTheme, setCustomStyleSheet)
|
||||
from .smooth_scroll import SmoothScroll, SmoothMode
|
||||
from .translator import FluentTranslator
|
||||
from .router import qrouter, Router
|
||||
from .color import FluentThemeColor, FluentSystemColor
|
||||
from .theme_listener import SystemThemeListener
|
||||
530
qfluentwidgets/common/animation.py
Normal file
530
qfluentwidgets/common/animation.py
Normal file
@ -0,0 +1,530 @@
|
||||
# coding: utf-8
|
||||
from enum import Enum
|
||||
from PySide6.QtCore import QEasingCurve, QEvent, QObject, QPropertyAnimation, Property, Signal, QPoint, QPointF
|
||||
from PySide6.QtGui import QMouseEvent, QEnterEvent, QColor
|
||||
from PySide6.QtWidgets import QWidget, QLineEdit, QGraphicsDropShadowEffect
|
||||
|
||||
from .config import qconfig
|
||||
|
||||
|
||||
class AnimationBase(QObject):
|
||||
""" Animation base class """
|
||||
|
||||
def __init__(self, parent: QWidget):
|
||||
super().__init__(parent=parent)
|
||||
parent.installEventFilter(self)
|
||||
|
||||
def _onHover(self, e: QEnterEvent):
|
||||
pass
|
||||
|
||||
def _onLeave(self, e: QEvent):
|
||||
pass
|
||||
|
||||
def _onPress(self, e: QMouseEvent):
|
||||
pass
|
||||
|
||||
def _onRelease(self, e: QMouseEvent):
|
||||
pass
|
||||
|
||||
def eventFilter(self, obj, e: QEvent):
|
||||
if obj is self.parent():
|
||||
if e.type() == QEvent.MouseButtonPress:
|
||||
self._onPress(e)
|
||||
elif e.type() == QEvent.MouseButtonRelease:
|
||||
self._onRelease(e)
|
||||
elif e.type() == QEvent.Enter:
|
||||
self._onHover(e)
|
||||
elif e.type() == QEvent.Leave:
|
||||
self._onLeave(e)
|
||||
|
||||
return super().eventFilter(obj, e)
|
||||
|
||||
|
||||
class TranslateYAnimation(AnimationBase):
|
||||
|
||||
valueChanged = Signal(float)
|
||||
|
||||
def __init__(self, parent: QWidget, offset=2):
|
||||
super().__init__(parent)
|
||||
self._y = 0
|
||||
self.maxOffset = offset
|
||||
self.ani = QPropertyAnimation(self, b'y', self)
|
||||
|
||||
def getY(self):
|
||||
return self._y
|
||||
|
||||
def setY(self, y):
|
||||
self._y = y
|
||||
self.parent().update()
|
||||
self.valueChanged.emit(y)
|
||||
|
||||
def _onPress(self, e):
|
||||
""" arrow down """
|
||||
self.ani.setEndValue(self.maxOffset)
|
||||
self.ani.setEasingCurve(QEasingCurve.OutQuad)
|
||||
self.ani.setDuration(150)
|
||||
self.ani.start()
|
||||
|
||||
def _onRelease(self, e):
|
||||
""" arrow up """
|
||||
self.ani.setEndValue(0)
|
||||
self.ani.setDuration(500)
|
||||
self.ani.setEasingCurve(QEasingCurve.OutElastic)
|
||||
self.ani.start()
|
||||
|
||||
y = Property(float, getY, setY)
|
||||
|
||||
|
||||
|
||||
class BackgroundAnimationWidget:
|
||||
""" Background animation widget """
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.isHover = False
|
||||
self.isPressed = False
|
||||
self.bgColorObject = BackgroundColorObject(self)
|
||||
self.backgroundColorAni = QPropertyAnimation(
|
||||
self.bgColorObject, b'backgroundColor', self)
|
||||
self.backgroundColorAni.setDuration(120)
|
||||
self.installEventFilter(self)
|
||||
|
||||
qconfig.themeChanged.connect(self._updateBackgroundColor)
|
||||
|
||||
def eventFilter(self, obj, e):
|
||||
if obj is self:
|
||||
if e.type() == QEvent.Type.EnabledChange:
|
||||
if self.isEnabled():
|
||||
self.setBackgroundColor(self._normalBackgroundColor())
|
||||
else:
|
||||
self.setBackgroundColor(self._disabledBackgroundColor())
|
||||
|
||||
return super().eventFilter(obj, e)
|
||||
|
||||
def mousePressEvent(self, e):
|
||||
self.isPressed = True
|
||||
self._updateBackgroundColor()
|
||||
super().mousePressEvent(e)
|
||||
|
||||
def mouseReleaseEvent(self, e):
|
||||
self.isPressed = False
|
||||
self._updateBackgroundColor()
|
||||
super().mouseReleaseEvent(e)
|
||||
|
||||
def enterEvent(self, e):
|
||||
self.isHover = True
|
||||
self._updateBackgroundColor()
|
||||
|
||||
def leaveEvent(self, e):
|
||||
self.isHover = False
|
||||
self._updateBackgroundColor()
|
||||
|
||||
def focusInEvent(self, e):
|
||||
super().focusInEvent(e)
|
||||
self._updateBackgroundColor()
|
||||
|
||||
def _normalBackgroundColor(self):
|
||||
return QColor(0, 0, 0, 0)
|
||||
|
||||
def _hoverBackgroundColor(self):
|
||||
return self._normalBackgroundColor()
|
||||
|
||||
def _pressedBackgroundColor(self):
|
||||
return self._normalBackgroundColor()
|
||||
|
||||
def _focusInBackgroundColor(self):
|
||||
return self._normalBackgroundColor()
|
||||
|
||||
def _disabledBackgroundColor(self):
|
||||
return self._normalBackgroundColor()
|
||||
|
||||
def _updateBackgroundColor(self):
|
||||
if not self.isEnabled():
|
||||
color = self._disabledBackgroundColor()
|
||||
elif isinstance(self, QLineEdit) and self.hasFocus():
|
||||
color = self._focusInBackgroundColor()
|
||||
elif self.isPressed:
|
||||
color = self._pressedBackgroundColor()
|
||||
elif self.isHover:
|
||||
color = self._hoverBackgroundColor()
|
||||
else:
|
||||
color = self._normalBackgroundColor()
|
||||
|
||||
self.backgroundColorAni.stop()
|
||||
self.backgroundColorAni.setEndValue(color)
|
||||
self.backgroundColorAni.start()
|
||||
|
||||
def getBackgroundColor(self):
|
||||
return self.bgColorObject.backgroundColor
|
||||
|
||||
def setBackgroundColor(self, color: QColor):
|
||||
self.bgColorObject.backgroundColor = color
|
||||
|
||||
@property
|
||||
def backgroundColor(self):
|
||||
return self.getBackgroundColor()
|
||||
|
||||
|
||||
class BackgroundColorObject(QObject):
|
||||
""" Background color object """
|
||||
|
||||
def __init__(self, parent: BackgroundAnimationWidget):
|
||||
super().__init__(parent)
|
||||
self._backgroundColor = parent._normalBackgroundColor()
|
||||
|
||||
@Property(QColor)
|
||||
def backgroundColor(self):
|
||||
return self._backgroundColor
|
||||
|
||||
@backgroundColor.setter
|
||||
def backgroundColor(self, color: QColor):
|
||||
self._backgroundColor = color
|
||||
self.parent().update()
|
||||
|
||||
class DropShadowAnimation(QPropertyAnimation):
|
||||
""" Drop shadow animation """
|
||||
|
||||
def __init__(self, parent: QWidget, normalColor=QColor(0, 0, 0, 0), hoverColor=QColor(0, 0, 0, 75)):
|
||||
super().__init__(parent=parent)
|
||||
self.normalColor = normalColor
|
||||
self.hoverColor = hoverColor
|
||||
self.offset = QPoint(0, 0)
|
||||
self.blurRadius = 38
|
||||
self.isHover = False
|
||||
|
||||
self.shadowEffect = QGraphicsDropShadowEffect(self)
|
||||
self.shadowEffect.setColor(self.normalColor)
|
||||
|
||||
parent.installEventFilter(self)
|
||||
|
||||
def setBlurRadius(self, radius: int):
|
||||
self.blurRadius = radius
|
||||
|
||||
def setOffset(self, dx: int, dy: int):
|
||||
self.offset = QPoint(dx, dy)
|
||||
|
||||
def setNormalColor(self, color: QColor):
|
||||
self.normalColor = color
|
||||
|
||||
def setHoverColor(self, color: QColor):
|
||||
self.hoverColor = color
|
||||
|
||||
def setColor(self, color):
|
||||
pass
|
||||
|
||||
def _createShadowEffect(self):
|
||||
self.shadowEffect = QGraphicsDropShadowEffect(self)
|
||||
self.shadowEffect.setOffset(self.offset)
|
||||
self.shadowEffect.setBlurRadius(self.blurRadius)
|
||||
self.shadowEffect.setColor(self.normalColor)
|
||||
|
||||
self.setTargetObject(self.shadowEffect)
|
||||
self.setStartValue(self.shadowEffect.color())
|
||||
self.setPropertyName(b'color')
|
||||
self.setDuration(150)
|
||||
|
||||
return self.shadowEffect
|
||||
|
||||
def eventFilter(self, obj, e):
|
||||
if obj is self.parent() and self.parent().isEnabled():
|
||||
if e.type() in [QEvent.Type.Enter]:
|
||||
self.isHover = True
|
||||
|
||||
if self.state() != QPropertyAnimation.State.Running:
|
||||
self.parent().setGraphicsEffect(self._createShadowEffect())
|
||||
|
||||
self.setEndValue(self.hoverColor)
|
||||
self.start()
|
||||
elif e.type() in [QEvent.Type.Leave, QEvent.Type.MouseButtonPress]:
|
||||
self.isHover = False
|
||||
if self.parent().graphicsEffect():
|
||||
self.finished.connect(self._onAniFinished)
|
||||
self.setEndValue(self.normalColor)
|
||||
self.start()
|
||||
|
||||
return super().eventFilter(obj, e)
|
||||
|
||||
def _onAniFinished(self):
|
||||
self.finished.disconnect()
|
||||
self.shadowEffect = None
|
||||
self.parent().setGraphicsEffect(None)
|
||||
|
||||
|
||||
class FluentAnimationSpeed(Enum):
|
||||
""" Fluent animation speed """
|
||||
FAST = 0
|
||||
MEDIUM = 1
|
||||
SLOW = 2
|
||||
|
||||
|
||||
class FluentAnimationType(Enum):
|
||||
""" Fluent animation type """
|
||||
FAST_INVOKE = 0
|
||||
STRONG_INVOKE = 1
|
||||
FAST_DISMISS = 2
|
||||
SOFT_DISMISS = 3
|
||||
POINT_TO_POINT = 4
|
||||
FADE_IN_OUT = 5
|
||||
|
||||
|
||||
class FluentAnimationProperty(Enum):
|
||||
""" Fluent animation property """
|
||||
POSITION = "position"
|
||||
SCALE = "scale"
|
||||
ANGLE = "angle"
|
||||
OPACITY = "opacity"
|
||||
|
||||
|
||||
|
||||
class FluentAnimationProperObject(QObject):
|
||||
""" Fluent animation property object """
|
||||
|
||||
objects = {}
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
def getValue(self):
|
||||
return 0
|
||||
|
||||
def setValue(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def register(cls, name):
|
||||
""" register menu animation manager
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name: Any
|
||||
the name of manager, it should be unique
|
||||
"""
|
||||
def wrapper(Manager):
|
||||
if name not in cls.objects:
|
||||
cls.objects[name] = Manager
|
||||
|
||||
return Manager
|
||||
|
||||
return wrapper
|
||||
|
||||
@classmethod
|
||||
def create(cls, propertyType: FluentAnimationProperty, parent=None):
|
||||
if propertyType not in cls.objects:
|
||||
raise ValueError(f"`{propertyType}` has not been registered")
|
||||
|
||||
return cls.objects[propertyType](parent)
|
||||
|
||||
|
||||
@FluentAnimationProperObject.register(FluentAnimationProperty.POSITION)
|
||||
class PositionObject(FluentAnimationProperObject):
|
||||
""" Position object """
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._position = QPoint()
|
||||
|
||||
def getValue(self):
|
||||
return self._position
|
||||
|
||||
def setValue(self, pos: QPoint):
|
||||
self._position = pos
|
||||
self.parent().update()
|
||||
|
||||
position = Property(QPoint, getValue, setValue)
|
||||
|
||||
|
||||
@FluentAnimationProperObject.register(FluentAnimationProperty.SCALE)
|
||||
class ScaleObject(FluentAnimationProperObject):
|
||||
""" Scale object """
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._scale = 1
|
||||
|
||||
def getValue(self):
|
||||
return self._scale
|
||||
|
||||
def setValue(self, scale: float):
|
||||
self._scale = scale
|
||||
self.parent().update()
|
||||
|
||||
scale = Property(float, getValue, setValue)
|
||||
|
||||
|
||||
@FluentAnimationProperObject.register(FluentAnimationProperty.ANGLE)
|
||||
class AngleObject(FluentAnimationProperObject):
|
||||
""" Angle object """
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._angle = 0
|
||||
|
||||
def getValue(self):
|
||||
return self._angle
|
||||
|
||||
def setValue(self, angle: float):
|
||||
self._angle = angle
|
||||
self.parent().update()
|
||||
|
||||
angle = Property(float, getValue, setValue)
|
||||
|
||||
|
||||
@FluentAnimationProperObject.register(FluentAnimationProperty.OPACITY)
|
||||
class OpacityObject(FluentAnimationProperObject):
|
||||
""" Opacity object """
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._opacity = 0
|
||||
|
||||
def getValue(self):
|
||||
return self._opacity
|
||||
|
||||
def setValue(self, opacity: float):
|
||||
self._opacity = opacity
|
||||
self.parent().update()
|
||||
|
||||
opacity = Property(float, getValue, setValue)
|
||||
|
||||
|
||||
class FluentAnimation(QPropertyAnimation):
|
||||
""" Fluent animation base """
|
||||
|
||||
animations = {}
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.setSpeed(FluentAnimationSpeed.FAST)
|
||||
self.setEasingCurve(self.curve())
|
||||
|
||||
@classmethod
|
||||
def createBezierCurve(cls, x1, y1, x2, y2):
|
||||
curve = QEasingCurve(QEasingCurve.BezierSpline)
|
||||
curve.addCubicBezierSegment(QPointF(x1, y1), QPointF(x2, y2), QPointF(1, 1))
|
||||
return curve
|
||||
|
||||
@classmethod
|
||||
def curve(cls):
|
||||
return cls.createBezierCurve(0, 0, 1, 1)
|
||||
|
||||
def setSpeed(self, speed: FluentAnimationSpeed):
|
||||
""" set the speed of animation """
|
||||
self.setDuration(self.speedToDuration(speed))
|
||||
|
||||
def speedToDuration(self, speed: FluentAnimationSpeed):
|
||||
return 100
|
||||
|
||||
def startAnimation(self, endValue, startValue=None):
|
||||
self.stop()
|
||||
|
||||
if startValue is None:
|
||||
self.setStartValue(self.value())
|
||||
else:
|
||||
self.setStartValue(startValue)
|
||||
|
||||
self.setEndValue(endValue)
|
||||
self.start()
|
||||
|
||||
def value(self):
|
||||
return self.targetObject().getValue()
|
||||
|
||||
def setValue(self, value):
|
||||
self.targetObject().setValue(value)
|
||||
|
||||
@classmethod
|
||||
def register(cls, name):
|
||||
""" register menu animation manager
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name: Any
|
||||
the name of manager, it should be unique
|
||||
"""
|
||||
def wrapper(Manager):
|
||||
if name not in cls.animations:
|
||||
cls.animations[name] = Manager
|
||||
|
||||
return Manager
|
||||
|
||||
return wrapper
|
||||
|
||||
@classmethod
|
||||
def create(cls, aniType: FluentAnimationType, propertyType: FluentAnimationProperty,
|
||||
speed=FluentAnimationSpeed.FAST, value=None, parent=None) -> "FluentAnimation":
|
||||
if aniType not in cls.animations:
|
||||
raise ValueError(f"`{aniType}` has not been registered.")
|
||||
|
||||
obj = FluentAnimationProperObject.create(propertyType, parent)
|
||||
ani = cls.animations[aniType](parent)
|
||||
|
||||
ani.setSpeed(speed)
|
||||
ani.setTargetObject(obj)
|
||||
ani.setPropertyName(propertyType.value.encode())
|
||||
|
||||
if value is not None:
|
||||
ani.setValue(value)
|
||||
|
||||
return ani
|
||||
|
||||
|
||||
@FluentAnimation.register(FluentAnimationType.FAST_INVOKE)
|
||||
class FastInvokeAnimation(FluentAnimation):
|
||||
""" Fast invoke animation """
|
||||
|
||||
@classmethod
|
||||
def curve(cls):
|
||||
return cls.createBezierCurve(0, 0, 0, 1)
|
||||
|
||||
def speedToDuration(self, speed: FluentAnimationSpeed):
|
||||
if speed == FluentAnimationSpeed.FAST:
|
||||
return 187
|
||||
if speed == FluentAnimationSpeed.MEDIUM:
|
||||
return 333
|
||||
|
||||
return 500
|
||||
|
||||
|
||||
@FluentAnimation.register(FluentAnimationType.STRONG_INVOKE)
|
||||
class StrongInvokeAnimation(FluentAnimation):
|
||||
""" Strong invoke animation """
|
||||
|
||||
@classmethod
|
||||
def curve(cls):
|
||||
return cls.createBezierCurve(0.13, 1.62, 0, 0.92)
|
||||
|
||||
def speedToDuration(self, speed: FluentAnimationSpeed):
|
||||
return 667
|
||||
|
||||
|
||||
@FluentAnimation.register(FluentAnimationType.FAST_DISMISS)
|
||||
class FastDismissAnimation(FastInvokeAnimation):
|
||||
""" Fast dismiss animation """
|
||||
|
||||
|
||||
@FluentAnimation.register(FluentAnimationType.SOFT_DISMISS)
|
||||
class SoftDismissAnimation(FluentAnimation):
|
||||
""" Soft dismiss animation """
|
||||
|
||||
@classmethod
|
||||
def curve(cls):
|
||||
return cls.createBezierCurve(1, 0, 1, 1)
|
||||
|
||||
def speedToDuration(self, speed: FluentAnimationSpeed):
|
||||
return 167
|
||||
|
||||
|
||||
@FluentAnimation.register(FluentAnimationType.POINT_TO_POINT)
|
||||
class PointToPointAnimation(FastDismissAnimation):
|
||||
""" Point to point animation """
|
||||
|
||||
@classmethod
|
||||
def curve(cls):
|
||||
return cls.createBezierCurve(0.55, 0.55, 0, 1)
|
||||
|
||||
|
||||
@FluentAnimation.register(FluentAnimationType.FADE_IN_OUT)
|
||||
class FadeInOutAnimation(FluentAnimation):
|
||||
""" Fade in/out animation """
|
||||
|
||||
def speedToDuration(self, speed: FluentAnimationSpeed):
|
||||
return 83
|
||||
164
qfluentwidgets/common/auto_wrap.py
Normal file
164
qfluentwidgets/common/auto_wrap.py
Normal file
@ -0,0 +1,164 @@
|
||||
from enum import Enum, auto
|
||||
from functools import lru_cache
|
||||
from re import sub
|
||||
from typing import List, Optional, Tuple
|
||||
from unicodedata import east_asian_width
|
||||
|
||||
|
||||
class CharType(Enum):
|
||||
SPACE = auto()
|
||||
ASIAN = auto()
|
||||
LATIN = auto()
|
||||
|
||||
|
||||
class TextWrap:
|
||||
"""Text wrap"""
|
||||
|
||||
EAST_ASAIN_WIDTH_TABLE = {
|
||||
"F": 2,
|
||||
"H": 1,
|
||||
"W": 2,
|
||||
"A": 1,
|
||||
"N": 1,
|
||||
"Na": 1,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=128)
|
||||
def get_width(cls, char: str) -> int:
|
||||
"""Returns the width of the char"""
|
||||
return cls.EAST_ASAIN_WIDTH_TABLE.get(east_asian_width(char), 1)
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=32)
|
||||
def get_text_width(cls, text: str) -> int:
|
||||
"""Returns the width of the text"""
|
||||
return sum(cls.get_width(char) for char in text)
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=128)
|
||||
def get_char_type(cls, char: str) -> CharType:
|
||||
"""Returns the type of the char"""
|
||||
|
||||
if char.isspace():
|
||||
return CharType.SPACE
|
||||
|
||||
if cls.get_width(char) == 1:
|
||||
return CharType.LATIN
|
||||
|
||||
return CharType.ASIAN
|
||||
|
||||
@classmethod
|
||||
def process_text_whitespace(cls, text: str) -> str:
|
||||
"""Process whitespace and leading and trailing spaces in strings"""
|
||||
return sub(pattern=r"\s+", repl=" ", string=text).strip()
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=32)
|
||||
def split_long_token(cls, token: str, width: int) -> List[str]:
|
||||
"""Split long token into smaller chunks."""
|
||||
return [token[i : i + width] for i in range(0, len(token), width)]
|
||||
|
||||
@classmethod
|
||||
def tokenizer(cls, text: str):
|
||||
"""tokenize line"""
|
||||
|
||||
buffer = ""
|
||||
last_char_type: Optional[CharType] = None
|
||||
|
||||
for char in text:
|
||||
char_type = cls.get_char_type(char)
|
||||
|
||||
if buffer and (char_type != last_char_type or char_type != CharType.LATIN):
|
||||
yield buffer
|
||||
buffer = ""
|
||||
|
||||
buffer += char
|
||||
last_char_type = char_type
|
||||
|
||||
yield buffer
|
||||
|
||||
@classmethod
|
||||
def wrap(cls, text: str, width: int, once: bool = True) -> Tuple[str, bool]:
|
||||
"""Wrap according to string length
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text: str
|
||||
the text to be wrapped
|
||||
|
||||
width: int
|
||||
the maximum length of a single line, the length of Chinese characters is 2
|
||||
|
||||
once: bool
|
||||
whether to wrap only once
|
||||
|
||||
Returns
|
||||
-------
|
||||
wrap_text: str
|
||||
text after auto word wrap process
|
||||
|
||||
is_wrapped: bool
|
||||
whether a line break occurs in the text
|
||||
"""
|
||||
|
||||
width = int(width)
|
||||
lines = text.splitlines()
|
||||
is_wrapped = False
|
||||
wrapped_lines = []
|
||||
|
||||
for line in lines:
|
||||
line = cls.process_text_whitespace(line)
|
||||
|
||||
if cls.get_text_width(line) > width:
|
||||
wrapped_line, is_wrapped = cls._wrap_line(line, width, once)
|
||||
wrapped_lines.append(wrapped_line)
|
||||
|
||||
if once:
|
||||
wrapped_lines.append(text[len(wrapped_line) :].rstrip())
|
||||
return "".join(wrapped_lines), is_wrapped
|
||||
|
||||
else:
|
||||
wrapped_lines.append(line)
|
||||
|
||||
return "\n".join(wrapped_lines), is_wrapped
|
||||
|
||||
@classmethod
|
||||
def _wrap_line(cls, text: str, width: int, once: bool = True) -> Tuple[str, bool]:
|
||||
line_buffer = ""
|
||||
wrapped_lines = []
|
||||
current_width = 0
|
||||
|
||||
for token in cls.tokenizer(text):
|
||||
token_width = cls.get_text_width(token)
|
||||
|
||||
if token == " " and current_width == 0:
|
||||
continue
|
||||
|
||||
if current_width + token_width <= width:
|
||||
line_buffer += token
|
||||
current_width += token_width
|
||||
|
||||
if current_width == width:
|
||||
wrapped_lines.append(line_buffer.rstrip())
|
||||
line_buffer = ""
|
||||
current_width = 0
|
||||
else:
|
||||
if current_width != 0:
|
||||
wrapped_lines.append(line_buffer.rstrip())
|
||||
|
||||
chunks = cls.split_long_token(token, width)
|
||||
|
||||
for chunk in chunks[:-1]:
|
||||
wrapped_lines.append(chunk.rstrip())
|
||||
|
||||
line_buffer = chunks[-1]
|
||||
current_width = cls.get_text_width(chunks[-1])
|
||||
|
||||
if current_width != 0:
|
||||
wrapped_lines.append(line_buffer.rstrip())
|
||||
|
||||
if once:
|
||||
return "\n".join([wrapped_lines[0], " ".join(wrapped_lines[1:])]), True
|
||||
|
||||
return "\n".join(wrapped_lines), True
|
||||
95
qfluentwidgets/common/color.py
Normal file
95
qfluentwidgets/common/color.py
Normal file
@ -0,0 +1,95 @@
|
||||
# coding: utf-8
|
||||
from enum import Enum
|
||||
|
||||
from PySide6.QtGui import QColor
|
||||
|
||||
from .style_sheet import themeColor, Theme, isDarkTheme
|
||||
from .config import isDarkThemeMode
|
||||
|
||||
|
||||
class FluentThemeColor(Enum):
|
||||
""" Fluent theme color
|
||||
|
||||
Refer to: https://www.figma.com/file/iM7EPX8Jn37zjeSezb43cF
|
||||
"""
|
||||
YELLOW_GOLD = "#FFB900"
|
||||
GOLD = "#FF8C00"
|
||||
ORANGE_BRIGHT = "#F7630C"
|
||||
ORANGE_DARK = "#CA5010"
|
||||
RUST = "#DA3B01"
|
||||
PALE_RUST = "#EF6950"
|
||||
BRICK_RED = "#D13438"
|
||||
MOD_RED = "#FF4343"
|
||||
PALE_RED = "#E74856"
|
||||
RED = "#E81123"
|
||||
ROSE_BRIGHT = "#EA005E"
|
||||
ROSE = "#C30052"
|
||||
PLUM_LIGHT = "#E3008C"
|
||||
PLUM = "#BF0077"
|
||||
ORCHID_LIGHT = "#BF0077"
|
||||
ORCHID = "#9A0089"
|
||||
DEFAULT_BLUE = "#0078D7"
|
||||
NAVY_BLUE = "#0063B1"
|
||||
PURPLE_SHADOW = "#8E8CD8"
|
||||
PURPLE_SHADOW_DARK = "#6B69D6"
|
||||
IRIS_PASTEL = "#8764B8"
|
||||
IRIS_SPRING = "#744DA9"
|
||||
VIOLET_RED_LIGHT = "#B146C2"
|
||||
VIOLET_RED = "#881798"
|
||||
COOL_BLUE_BRIGHT = "#0099BC"
|
||||
COOL_BLUR = "#2D7D9A"
|
||||
SEAFOAM = "#00B7C3"
|
||||
SEAFOAM_TEAL = "#038387"
|
||||
MINT_LIGHT = "#00B294"
|
||||
MINT_DARK = "#018574"
|
||||
TURF_GREEN = "#00CC6A"
|
||||
SPORT_GREEN = "#10893E"
|
||||
GRAY = "#7A7574"
|
||||
GRAY_BROWN = "#5D5A58"
|
||||
STEAL_BLUE = "#68768A"
|
||||
METAL_BLUE = "#515C6B"
|
||||
PALE_MOSS = "#567C73"
|
||||
MOSS = "#486860"
|
||||
MEADOW_GREEN = "#498205"
|
||||
GREEN = "#107C10"
|
||||
OVERCAST = "#767676"
|
||||
STORM = "#4C4A48"
|
||||
BLUE_GRAY = "#69797E"
|
||||
GRAY_DARK = "#4A5459"
|
||||
LIDDY_GREEN = "#647C64"
|
||||
SAGE = "#525E54"
|
||||
CAMOUFLAGE_DESERT = "#847545"
|
||||
CAMOUFLAGE = "#7E735F"
|
||||
|
||||
def color(self):
|
||||
return QColor(self.value)
|
||||
|
||||
|
||||
|
||||
class FluentSystemColor(Enum):
|
||||
|
||||
SUCCESS_FOREGROUND = ("#0f7b0f", "#6ccb5f")
|
||||
CAUTION_FOREGROUND = ("#9d5d00", "#fce100")
|
||||
CRITICAL_FOREGROUND = ("#c42b1c", "#ff99a4")
|
||||
|
||||
SUCCESS_BACKGROUND = ("#dff6dd", "#393d1b")
|
||||
CAUTION_BACKGROUND = ("#fff4ce", "#433519")
|
||||
CRITICAL_BACKGROUND = ("#fde7e9", "#442726")
|
||||
|
||||
def color(self, theme=Theme.AUTO) -> QColor:
|
||||
color = self.value[1] if isDarkThemeMode(theme) else self.value[0]
|
||||
return QColor(color)
|
||||
|
||||
|
||||
|
||||
def validColor(color: QColor, default: QColor) -> QColor:
|
||||
return color if color.isValid() else default
|
||||
|
||||
|
||||
def fallbackThemeColor(color: QColor):
|
||||
return color if color.isValid() else themeColor()
|
||||
|
||||
|
||||
def autoFallbackThemeColor(light: QColor, dark: QColor):
|
||||
color = dark if isDarkTheme() else light
|
||||
return fallbackThemeColor(color)
|
||||
423
qfluentwidgets/common/config.py
Normal file
423
qfluentwidgets/common/config.py
Normal file
@ -0,0 +1,423 @@
|
||||
# coding:utf-8
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import darkdetect
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
from PySide6.QtGui import QColor
|
||||
|
||||
from .exception_handler import exceptionHandler
|
||||
|
||||
|
||||
ALERT = "\n\033[1;33m📢 Tips:\033[0m QFluentWidgets Pro is now released. Click \033[1;96mhttps://qfluentwidgets.com/pages/pro\033[0m to learn more about it.\n"
|
||||
|
||||
|
||||
class Theme(Enum):
|
||||
""" Theme enumeration """
|
||||
|
||||
LIGHT = "Light"
|
||||
DARK = "Dark"
|
||||
AUTO = "Auto"
|
||||
|
||||
|
||||
class ConfigValidator:
|
||||
""" Config validator """
|
||||
|
||||
def validate(self, value):
|
||||
""" Verify whether the value is legal """
|
||||
return True
|
||||
|
||||
def correct(self, value):
|
||||
""" correct illegal value """
|
||||
return value
|
||||
|
||||
|
||||
class RangeValidator(ConfigValidator):
|
||||
""" Range validator """
|
||||
|
||||
def __init__(self, min, max):
|
||||
self.min = min
|
||||
self.max = max
|
||||
self.range = (min, max)
|
||||
|
||||
def validate(self, value):
|
||||
return self.min <= value <= self.max
|
||||
|
||||
def correct(self, value):
|
||||
return min(max(self.min, value), self.max)
|
||||
|
||||
|
||||
class OptionsValidator(ConfigValidator):
|
||||
""" Options validator """
|
||||
|
||||
def __init__(self, options):
|
||||
if not options:
|
||||
raise ValueError("The `options` can't be empty.")
|
||||
|
||||
if isinstance(options, Enum):
|
||||
options = options._member_map_.values()
|
||||
|
||||
self.options = list(options)
|
||||
|
||||
def validate(self, value):
|
||||
return value in self.options
|
||||
|
||||
def correct(self, value):
|
||||
return value if self.validate(value) else self.options[0]
|
||||
|
||||
|
||||
class BoolValidator(OptionsValidator):
|
||||
""" Boolean validator """
|
||||
|
||||
def __init__(self):
|
||||
super().__init__([True, False])
|
||||
|
||||
|
||||
class FolderValidator(ConfigValidator):
|
||||
""" Folder validator """
|
||||
|
||||
def validate(self, value):
|
||||
return Path(value).exists()
|
||||
|
||||
def correct(self, value):
|
||||
path = Path(value)
|
||||
path.mkdir(exist_ok=True, parents=True)
|
||||
return str(path.absolute()).replace("\\", "/")
|
||||
|
||||
|
||||
class FolderListValidator(ConfigValidator):
|
||||
""" Folder list validator """
|
||||
|
||||
def validate(self, value):
|
||||
return all(Path(i).exists() for i in value)
|
||||
|
||||
def correct(self, value: List[str]):
|
||||
folders = []
|
||||
for folder in value:
|
||||
path = Path(folder)
|
||||
if path.exists():
|
||||
folders.append(str(path.absolute()).replace("\\", "/"))
|
||||
|
||||
return folders
|
||||
|
||||
|
||||
class ColorValidator(ConfigValidator):
|
||||
""" RGB color validator """
|
||||
|
||||
def __init__(self, default):
|
||||
self.default = QColor(default)
|
||||
|
||||
def validate(self, color):
|
||||
try:
|
||||
return QColor(color).isValid()
|
||||
except:
|
||||
return False
|
||||
|
||||
def correct(self, value):
|
||||
return QColor(value) if self.validate(value) else self.default
|
||||
|
||||
|
||||
class ConfigSerializer:
|
||||
""" Config serializer """
|
||||
|
||||
def serialize(self, value):
|
||||
""" serialize config value """
|
||||
return value
|
||||
|
||||
def deserialize(self, value):
|
||||
""" deserialize config from config file's value """
|
||||
return value
|
||||
|
||||
|
||||
class EnumSerializer(ConfigSerializer):
|
||||
""" enumeration class serializer """
|
||||
|
||||
def __init__(self, enumClass):
|
||||
self.enumClass = enumClass
|
||||
|
||||
def serialize(self, value):
|
||||
return value.value
|
||||
|
||||
def deserialize(self, value):
|
||||
return self.enumClass(value)
|
||||
|
||||
|
||||
class ColorSerializer(ConfigSerializer):
|
||||
""" QColor serializer """
|
||||
|
||||
def serialize(self, value: QColor):
|
||||
return value.name(QColor.HexArgb)
|
||||
|
||||
def deserialize(self, value):
|
||||
if isinstance(value, list):
|
||||
return QColor(*value)
|
||||
|
||||
return QColor(value)
|
||||
|
||||
|
||||
class ConfigItem(QObject):
|
||||
""" Config item """
|
||||
|
||||
valueChanged = Signal(object)
|
||||
|
||||
def __init__(self, group, name, default, validator=None, serializer=None, restart=False):
|
||||
"""
|
||||
Parameters
|
||||
----------
|
||||
group: str
|
||||
config group name
|
||||
|
||||
name: str
|
||||
config item name, can be empty
|
||||
|
||||
default:
|
||||
default value
|
||||
|
||||
options: list
|
||||
options value
|
||||
|
||||
serializer: ConfigSerializer
|
||||
config serializer
|
||||
|
||||
restart: bool
|
||||
whether to restart the application after updating value
|
||||
"""
|
||||
super().__init__()
|
||||
self.group = group
|
||||
self.name = name
|
||||
self.validator = validator or ConfigValidator()
|
||||
self.serializer = serializer or ConfigSerializer()
|
||||
self.__value = default
|
||||
self.value = default
|
||||
self.restart = restart
|
||||
self.defaultValue = self.validator.correct(default)
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
""" get the value of config item """
|
||||
return self.__value
|
||||
|
||||
@value.setter
|
||||
def value(self, v):
|
||||
v = self.validator.correct(v)
|
||||
ov = self.__value
|
||||
self.__value = v
|
||||
if ov != v:
|
||||
self.valueChanged.emit(v)
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
""" get the config key separated by `.` """
|
||||
return self.group+"."+self.name if self.name else self.group
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.__class__.__name__}[value={self.value}]'
|
||||
|
||||
def serialize(self):
|
||||
return self.serializer.serialize(self.value)
|
||||
|
||||
def deserializeFrom(self, value):
|
||||
self.value = self.serializer.deserialize(value)
|
||||
|
||||
|
||||
class RangeConfigItem(ConfigItem):
|
||||
""" Config item of range """
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
""" get the available range of config """
|
||||
return self.validator.range
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.__class__.__name__}[range={self.range}, value={self.value}]'
|
||||
|
||||
|
||||
class OptionsConfigItem(ConfigItem):
|
||||
""" Config item with options """
|
||||
|
||||
@property
|
||||
def options(self):
|
||||
return self.validator.options
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.__class__.__name__}[options={self.options}, value={self.value}]'
|
||||
|
||||
|
||||
class ColorConfigItem(ConfigItem):
|
||||
""" Color config item """
|
||||
|
||||
def __init__(self, group, name, default, restart=False):
|
||||
super().__init__(group, name, QColor(default), ColorValidator(default),
|
||||
ColorSerializer(), restart)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.__class__.__name__}[value={self.value.name()}]'
|
||||
|
||||
|
||||
class QConfig(QObject):
|
||||
""" Config of app """
|
||||
|
||||
appRestartSig = Signal()
|
||||
themeChanged = Signal(Theme)
|
||||
themeChangedFinished = Signal()
|
||||
themeColorChanged = Signal(QColor)
|
||||
|
||||
themeMode = OptionsConfigItem(
|
||||
"QFluentWidgets", "ThemeMode", Theme.LIGHT, OptionsValidator(Theme), EnumSerializer(Theme))
|
||||
themeColor = ColorConfigItem("QFluentWidgets", "ThemeColor", '#009faa')
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.file = Path("config/config.json")
|
||||
self._theme = Theme.LIGHT
|
||||
self._cfg = self
|
||||
|
||||
def get(self, item):
|
||||
""" get the value of config item """
|
||||
return item.value
|
||||
|
||||
def set(self, item, value, save=True, copy=True):
|
||||
""" set the value of config item
|
||||
|
||||
Parameters
|
||||
----------
|
||||
item: ConfigItem
|
||||
config item
|
||||
|
||||
value:
|
||||
the new value of config item
|
||||
|
||||
save: bool
|
||||
whether to save the change to config file
|
||||
|
||||
copy: bool
|
||||
whether to deep copy the new value
|
||||
"""
|
||||
if item.value == value:
|
||||
return
|
||||
|
||||
# deepcopy new value
|
||||
try:
|
||||
item.value = deepcopy(value) if copy else value
|
||||
except:
|
||||
item.value = value
|
||||
|
||||
if save:
|
||||
self.save()
|
||||
|
||||
if item.restart:
|
||||
self._cfg.appRestartSig.emit()
|
||||
|
||||
if item is self._cfg.themeMode:
|
||||
self.theme = value
|
||||
self._cfg.themeChanged.emit(value)
|
||||
|
||||
if item is self._cfg.themeColor:
|
||||
self._cfg.themeColorChanged.emit(value)
|
||||
|
||||
def toDict(self, serialize=True):
|
||||
""" convert config items to `dict` """
|
||||
items = {}
|
||||
for name in dir(self._cfg.__class__):
|
||||
item = getattr(self._cfg.__class__, name)
|
||||
if not isinstance(item, ConfigItem):
|
||||
continue
|
||||
|
||||
value = item.serialize() if serialize else item.value
|
||||
if not items.get(item.group):
|
||||
if not item.name:
|
||||
items[item.group] = value
|
||||
else:
|
||||
items[item.group] = {}
|
||||
|
||||
if item.name:
|
||||
items[item.group][item.name] = value
|
||||
|
||||
return items
|
||||
|
||||
def save(self):
|
||||
""" save config """
|
||||
self._cfg.file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._cfg.file, "w", encoding="utf-8") as f:
|
||||
json.dump(self._cfg.toDict(), f, ensure_ascii=False, indent=4)
|
||||
|
||||
@exceptionHandler()
|
||||
def load(self, file=None, config=None):
|
||||
""" load config
|
||||
|
||||
Parameters
|
||||
----------
|
||||
file: str or Path
|
||||
the path of json config file
|
||||
|
||||
config: Config
|
||||
config object to be initialized
|
||||
"""
|
||||
if isinstance(config, QConfig):
|
||||
self._cfg = config
|
||||
self._cfg.themeChanged.connect(self.themeChanged)
|
||||
|
||||
if isinstance(file, (str, Path)):
|
||||
self._cfg.file = Path(file)
|
||||
|
||||
try:
|
||||
with open(self._cfg.file, encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
except:
|
||||
cfg = {}
|
||||
|
||||
# map config items'key to item
|
||||
items = {}
|
||||
for name in dir(self._cfg.__class__):
|
||||
item = getattr(self._cfg.__class__, name)
|
||||
if isinstance(item, ConfigItem):
|
||||
items[item.key] = item
|
||||
|
||||
# update the value of config item
|
||||
for k, v in cfg.items():
|
||||
if not isinstance(v, dict) and items.get(k) is not None:
|
||||
items[k].deserializeFrom(v)
|
||||
elif isinstance(v, dict):
|
||||
for key, value in v.items():
|
||||
key = k + "." + key
|
||||
if items.get(key) is not None:
|
||||
items[key].deserializeFrom(value)
|
||||
|
||||
self.theme = self.get(self._cfg.themeMode)
|
||||
|
||||
@property
|
||||
def theme(self):
|
||||
""" get theme mode, can be `Theme.Light` or `Theme.Dark` """
|
||||
return self._cfg._theme
|
||||
|
||||
@theme.setter
|
||||
def theme(self, t):
|
||||
""" chaneg the theme without modifying the config file """
|
||||
if t == Theme.AUTO:
|
||||
t = darkdetect.theme()
|
||||
t = Theme(t) if t else Theme.LIGHT
|
||||
|
||||
self._cfg._theme = t
|
||||
|
||||
|
||||
qconfig = QConfig()
|
||||
try:
|
||||
print(ALERT)
|
||||
except UnicodeEncodeError:
|
||||
print(ALERT.replace("📢", ""))
|
||||
|
||||
|
||||
def isDarkTheme():
|
||||
""" whether the theme is dark mode """
|
||||
return qconfig.theme == Theme.DARK
|
||||
|
||||
def theme():
|
||||
""" get theme mode """
|
||||
return qconfig.theme
|
||||
|
||||
def isDarkThemeMode(theme=Theme.AUTO):
|
||||
""" whether the theme is dark mode """
|
||||
return theme == Theme.DARK if theme != Theme.AUTO else isDarkTheme()
|
||||
31
qfluentwidgets/common/exception_handler.py
Normal file
31
qfluentwidgets/common/exception_handler.py
Normal file
@ -0,0 +1,31 @@
|
||||
# coding:utf-8
|
||||
from copy import deepcopy
|
||||
|
||||
|
||||
|
||||
def exceptionHandler(*default):
|
||||
""" decorator for exception handling
|
||||
|
||||
Parameters
|
||||
----------
|
||||
*default:
|
||||
the default value returned when an exception occurs
|
||||
"""
|
||||
|
||||
def outer(func):
|
||||
|
||||
def inner(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except BaseException as e:
|
||||
value = deepcopy(default)
|
||||
if len(value) == 0:
|
||||
return None
|
||||
elif len(value) == 1:
|
||||
return value[0]
|
||||
|
||||
return value
|
||||
|
||||
return inner
|
||||
|
||||
return outer
|
||||
38
qfluentwidgets/common/font.py
Normal file
38
qfluentwidgets/common/font.py
Normal file
@ -0,0 +1,38 @@
|
||||
# coding: utf-8
|
||||
from PySide6.QtGui import QFont
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
|
||||
def setFont(widget: QWidget, fontSize=14, weight=QFont.Normal):
|
||||
""" set the font of widget
|
||||
|
||||
Parameters
|
||||
----------
|
||||
widget: QWidget
|
||||
the widget to set font
|
||||
|
||||
fontSize: int
|
||||
font pixel size
|
||||
|
||||
weight: `QFont.Weight`
|
||||
font weight
|
||||
"""
|
||||
widget.setFont(getFont(fontSize, weight))
|
||||
|
||||
|
||||
def getFont(fontSize=14, weight=QFont.Normal):
|
||||
""" create font
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fontSize: int
|
||||
font pixel size
|
||||
|
||||
weight: `QFont.Weight`
|
||||
font weight
|
||||
"""
|
||||
font = QFont()
|
||||
font.setFamilies(['Segoe UI', 'Microsoft YaHei', 'PingFang SC'])
|
||||
font.setPixelSize(fontSize)
|
||||
font.setWeight(weight)
|
||||
return font
|
||||
703
qfluentwidgets/common/icon.py
Normal file
703
qfluentwidgets/common/icon.py
Normal file
@ -0,0 +1,703 @@
|
||||
# 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)
|
||||
198
qfluentwidgets/common/image_utils.py
Normal file
198
qfluentwidgets/common/image_utils.py
Normal file
@ -0,0 +1,198 @@
|
||||
# coding:utf-8
|
||||
from math import floor
|
||||
from io import BytesIO
|
||||
from typing import Union
|
||||
|
||||
import numpy as np
|
||||
from colorthief import ColorThief
|
||||
from PIL import Image
|
||||
from PySide6.QtGui import QImage, QPixmap
|
||||
from PySide6.QtCore import QIODevice, QBuffer
|
||||
from scipy.ndimage.filters import gaussian_filter
|
||||
|
||||
from .exception_handler import exceptionHandler
|
||||
|
||||
|
||||
|
||||
def gaussianBlur(image, blurRadius=18, brightFactor=1, blurPicSize= None):
|
||||
if isinstance(image, str) and not image.startswith(':'):
|
||||
image = Image.open(image)
|
||||
else:
|
||||
image = fromqpixmap(QPixmap(image))
|
||||
|
||||
if blurPicSize:
|
||||
# adjust image size to reduce computation
|
||||
w, h = image.size
|
||||
ratio = min(blurPicSize[0] / w, blurPicSize[1] / h)
|
||||
w_, h_ = w * ratio, h * ratio
|
||||
|
||||
if w_ < w:
|
||||
image = image.resize((int(w_), int(h_)), Image.ANTIALIAS)
|
||||
|
||||
image = np.array(image)
|
||||
|
||||
# handle gray image
|
||||
if len(image.shape) == 2:
|
||||
image = np.stack([image, image, image], axis=-1)
|
||||
|
||||
# blur each channel
|
||||
for i in range(3):
|
||||
image[:, :, i] = gaussian_filter(
|
||||
image[:, :, i], blurRadius) * brightFactor
|
||||
|
||||
# convert ndarray to QPixmap
|
||||
h, w, c = image.shape
|
||||
if c == 3:
|
||||
format = QImage.Format_RGB888
|
||||
else:
|
||||
format = QImage.Format_RGBA8888
|
||||
|
||||
return QPixmap.fromImage(QImage(image.data, w, h, c*w, format))
|
||||
|
||||
|
||||
# https://github.com/python-pillow/Pillow/blob/main/src/PIL/ImageQt.py
|
||||
def fromqpixmap(im: Union[QImage, QPixmap]):
|
||||
"""
|
||||
:param im: QImage or PIL ImageQt object
|
||||
"""
|
||||
buffer = QBuffer()
|
||||
buffer.open(QIODevice.OpenModeFlag.ReadWrite)
|
||||
|
||||
# preserve alpha channel with png
|
||||
# otherwise ppm is more friendly with Image.open
|
||||
if im.hasAlphaChannel():
|
||||
im.save(buffer, "png")
|
||||
else:
|
||||
im.save(buffer, "ppm")
|
||||
|
||||
b = BytesIO()
|
||||
b.write(buffer.data())
|
||||
buffer.close()
|
||||
b.seek(0)
|
||||
|
||||
return Image.open(b)
|
||||
|
||||
|
||||
class DominantColor:
|
||||
""" Dominant color class """
|
||||
|
||||
@classmethod
|
||||
@exceptionHandler((24, 24, 24))
|
||||
def getDominantColor(cls, imagePath):
|
||||
""" extract dominant color from image
|
||||
|
||||
Parameters
|
||||
----------
|
||||
imagePath: str
|
||||
image path
|
||||
|
||||
Returns
|
||||
-------
|
||||
r, g, b: int
|
||||
gray value of each color channel
|
||||
"""
|
||||
if imagePath.startswith(':'):
|
||||
return (24, 24, 24)
|
||||
|
||||
colorThief = ColorThief(imagePath)
|
||||
|
||||
# scale image to speed up the computation speed
|
||||
if max(colorThief.image.size) > 400:
|
||||
colorThief.image = colorThief.image.resize((400, 400))
|
||||
|
||||
palette = colorThief.get_palette(quality=9)
|
||||
|
||||
# adjust the brightness of palette
|
||||
palette = cls.__adjustPaletteValue(palette)
|
||||
for rgb in palette[:]:
|
||||
h, s, v = cls.rgb2hsv(rgb)
|
||||
if h < 0.02:
|
||||
palette.remove(rgb)
|
||||
if len(palette) <= 2:
|
||||
break
|
||||
|
||||
palette = palette[:5]
|
||||
palette.sort(key=lambda rgb: cls.colorfulness(*rgb), reverse=True)
|
||||
|
||||
return palette[0]
|
||||
|
||||
@classmethod
|
||||
def __adjustPaletteValue(cls, palette):
|
||||
""" adjust the brightness of palette """
|
||||
newPalette = []
|
||||
for rgb in palette:
|
||||
h, s, v = cls.rgb2hsv(rgb)
|
||||
if v > 0.9:
|
||||
factor = 0.8
|
||||
elif 0.8 < v <= 0.9:
|
||||
factor = 0.9
|
||||
elif 0.7 < v <= 0.8:
|
||||
factor = 0.95
|
||||
else:
|
||||
factor = 1
|
||||
v *= factor
|
||||
newPalette.append(cls.hsv2rgb(h, s, v))
|
||||
|
||||
return newPalette
|
||||
|
||||
@staticmethod
|
||||
def rgb2hsv(rgb):
|
||||
""" convert rgb to hsv """
|
||||
r, g, b = [i / 255 for i in rgb]
|
||||
mx = max(r, g, b)
|
||||
mn = min(r, g, b)
|
||||
df = mx - mn
|
||||
if mx == mn:
|
||||
h = 0
|
||||
elif mx == r:
|
||||
h = (60 * ((g - b) / df) + 360) % 360
|
||||
elif mx == g:
|
||||
h = (60 * ((b - r) / df) + 120) % 360
|
||||
elif mx == b:
|
||||
h = (60 * ((r - g) / df) + 240) % 360
|
||||
s = 0 if mx == 0 else df / mx
|
||||
v = mx
|
||||
return (h, s, v)
|
||||
|
||||
@staticmethod
|
||||
def hsv2rgb(h, s, v):
|
||||
""" convert hsv to rgb """
|
||||
h60 = h / 60.0
|
||||
h60f = floor(h60)
|
||||
hi = int(h60f) % 6
|
||||
f = h60 - h60f
|
||||
p = v * (1 - s)
|
||||
q = v * (1 - f * s)
|
||||
t = v * (1 - (1 - f) * s)
|
||||
r, g, b = 0, 0, 0
|
||||
if hi == 0:
|
||||
r, g, b = v, t, p
|
||||
elif hi == 1:
|
||||
r, g, b = q, v, p
|
||||
elif hi == 2:
|
||||
r, g, b = p, v, t
|
||||
elif hi == 3:
|
||||
r, g, b = p, q, v
|
||||
elif hi == 4:
|
||||
r, g, b = t, p, v
|
||||
elif hi == 5:
|
||||
r, g, b = v, p, q
|
||||
r, g, b = int(r * 255), int(g * 255), int(b * 255)
|
||||
return (r, g, b)
|
||||
|
||||
@staticmethod
|
||||
def colorfulness(r: int, g: int, b: int):
|
||||
rg = np.absolute(r - g)
|
||||
yb = np.absolute(0.5 * (r + g) - b)
|
||||
|
||||
# Compute the mean and standard deviation of both `rg` and `yb`.
|
||||
rg_mean, rg_std = (np.mean(rg), np.std(rg))
|
||||
yb_mean, yb_std = (np.mean(yb), np.std(yb))
|
||||
|
||||
# Combine the mean and standard deviations.
|
||||
std_root = np.sqrt((rg_std ** 2) + (yb_std ** 2))
|
||||
mean_root = np.sqrt((rg_mean ** 2) + (yb_mean ** 2))
|
||||
|
||||
return std_root + (0.3 * mean_root)
|
||||
|
||||
|
||||
47
qfluentwidgets/common/overload.py
Normal file
47
qfluentwidgets/common/overload.py
Normal file
@ -0,0 +1,47 @@
|
||||
# coding: utf-8
|
||||
from functools import singledispatch, update_wrapper
|
||||
|
||||
|
||||
class singledispatchmethod:
|
||||
"""Single-dispatch generic method descriptor.
|
||||
|
||||
Supports wrapping existing descriptors and handles non-descriptor
|
||||
callables as instance methods.
|
||||
"""
|
||||
|
||||
def __init__(self, func):
|
||||
if not callable(func) and not hasattr(func, "__get__"):
|
||||
raise TypeError(f"{func!r} is not callable or a descriptor")
|
||||
|
||||
self.dispatcher = singledispatch(func)
|
||||
self.func = func
|
||||
|
||||
def register(self, cls, method=None):
|
||||
"""generic_method.register(cls, func) -> func
|
||||
|
||||
Registers a new implementation for the given *cls* on a *generic_method*.
|
||||
"""
|
||||
return self.dispatcher.register(cls, func=method)
|
||||
|
||||
def __get__(self, obj, cls=None):
|
||||
def _method(*args, **kwargs):
|
||||
if args:
|
||||
method = self.dispatcher.dispatch(args[0].__class__)
|
||||
else:
|
||||
method = self.func
|
||||
for v in kwargs.values():
|
||||
if v.__class__ in self.dispatcher.registry:
|
||||
method = self.dispatcher.dispatch(v.__class__)
|
||||
if method is not self.func:
|
||||
break
|
||||
|
||||
return method.__get__(obj, cls)(*args, **kwargs)
|
||||
|
||||
_method.__isabstractmethod__ = self.__isabstractmethod__
|
||||
_method.register = self.register
|
||||
update_wrapper(_method, self.func)
|
||||
return _method
|
||||
|
||||
@property
|
||||
def __isabstractmethod__(self):
|
||||
return getattr(self.func, '__isabstractmethod__', False)
|
||||
133
qfluentwidgets/common/router.py
Normal file
133
qfluentwidgets/common/router.py
Normal file
@ -0,0 +1,133 @@
|
||||
# coding:utf-8
|
||||
from typing import Dict, List
|
||||
from itertools import groupby
|
||||
|
||||
from PySide6.QtCore import Qt, QObject, Signal
|
||||
from PySide6.QtWidgets import QWidget, QStackedWidget
|
||||
|
||||
|
||||
class RouteItem:
|
||||
""" Route item """
|
||||
|
||||
def __init__(self, stacked: QStackedWidget, routeKey: str):
|
||||
self.stacked = stacked
|
||||
self.routeKey = routeKey
|
||||
|
||||
def __eq__(self, other):
|
||||
if other is None:
|
||||
return False
|
||||
|
||||
return other.stacked is self.stacked and self.routeKey == other.routeKey
|
||||
|
||||
|
||||
class StackedHistory:
|
||||
""" Stacked history """
|
||||
|
||||
def __init__(self, stacked: QStackedWidget):
|
||||
self.stacked = stacked
|
||||
self.defaultRouteKey = None # type: str
|
||||
self.history = [self.defaultRouteKey] # type: List[str]
|
||||
|
||||
def __len__(self):
|
||||
return len(self.history)
|
||||
|
||||
def isEmpty(self):
|
||||
return len(self) <= 1
|
||||
|
||||
def push(self, routeKey: str):
|
||||
if self.history[-1] == routeKey:
|
||||
return False
|
||||
|
||||
self.history.append(routeKey)
|
||||
return True
|
||||
|
||||
def pop(self):
|
||||
if self.isEmpty():
|
||||
return
|
||||
|
||||
self.history.pop()
|
||||
self.goToTop()
|
||||
|
||||
def remove(self, routeKey: str):
|
||||
if routeKey not in self.history:
|
||||
return
|
||||
|
||||
self.history[1:] = [i for i in self.history[1:] if i != routeKey]
|
||||
self.history = [k for k, g in groupby(self.history)]
|
||||
self.goToTop()
|
||||
|
||||
def top(self):
|
||||
return self.history[-1]
|
||||
|
||||
def setDefaultRouteKey(self, routeKey: str):
|
||||
self.defaultRouteKey = routeKey
|
||||
self.history[0] = routeKey
|
||||
|
||||
def goToTop(self):
|
||||
w = self.stacked.findChild(QWidget, self.top())
|
||||
if w:
|
||||
self.stacked.setCurrentWidget(w)
|
||||
|
||||
|
||||
class Router(QObject):
|
||||
""" Router """
|
||||
|
||||
emptyChanged = Signal(bool)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.history = [] # type: List[RouteItem]
|
||||
self.stackHistories = {} # type: Dict[QStackedWidget, StackedHistory]
|
||||
|
||||
def setDefaultRouteKey(self, stacked: QStackedWidget, routeKey: str):
|
||||
""" set the default route key of stacked widget """
|
||||
if stacked not in self.stackHistories:
|
||||
self.stackHistories[stacked] = StackedHistory(stacked)
|
||||
|
||||
self.stackHistories[stacked].setDefaultRouteKey(routeKey)
|
||||
|
||||
def push(self, stacked: QStackedWidget, routeKey: str):
|
||||
""" push history
|
||||
|
||||
Parameters
|
||||
----------
|
||||
stacked: QStackedWidget
|
||||
stacked widget
|
||||
|
||||
routeKey: str
|
||||
route key of sub insterface, it should be the object name of sub interface
|
||||
"""
|
||||
item = RouteItem(stacked, routeKey)
|
||||
|
||||
if stacked not in self.stackHistories:
|
||||
self.stackHistories[stacked] = StackedHistory(stacked)
|
||||
|
||||
# don't add duplicated history
|
||||
success = self.stackHistories[stacked].push(routeKey)
|
||||
if success:
|
||||
self.history.append(item)
|
||||
|
||||
self.emptyChanged.emit(not bool(self.history))
|
||||
|
||||
def pop(self):
|
||||
""" pop history """
|
||||
if not self.history:
|
||||
return
|
||||
|
||||
item = self.history.pop()
|
||||
self.emptyChanged.emit(not bool(self.history))
|
||||
self.stackHistories[item.stacked].pop()
|
||||
|
||||
def remove(self, routeKey: str):
|
||||
""" remove history """
|
||||
self.history = [i for i in self.history if i.routeKey != routeKey]
|
||||
self.history = [list(g)[0] for k, g in groupby(self.history, lambda i: i.routeKey)]
|
||||
self.emptyChanged.emit(not bool(self.history))
|
||||
|
||||
for stacked, history in self.stackHistories.items():
|
||||
w = stacked.findChild(QWidget, routeKey)
|
||||
if w:
|
||||
return history.remove(routeKey)
|
||||
|
||||
|
||||
qrouter = Router()
|
||||
25
qfluentwidgets/common/screen.py
Normal file
25
qfluentwidgets/common/screen.py
Normal file
@ -0,0 +1,25 @@
|
||||
from PySide6.QtCore import QPoint, QRect
|
||||
from PySide6.QtGui import QCursor
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
|
||||
def getCurrentScreen():
|
||||
""" get current screen """
|
||||
cursorPos = QCursor.pos()
|
||||
|
||||
for s in QApplication.screens():
|
||||
if s.geometry().contains(cursorPos):
|
||||
return s
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def getCurrentScreenGeometry(avaliable=True):
|
||||
""" get current screen geometry """
|
||||
screen = getCurrentScreen() or QApplication.primaryScreen()
|
||||
|
||||
# this should not happen
|
||||
if not screen:
|
||||
return QRect(0, 0, 1920, 1080)
|
||||
|
||||
return screen.availableGeometry() if avaliable else screen.geometry()
|
||||
141
qfluentwidgets/common/smooth_scroll.py
Normal file
141
qfluentwidgets/common/smooth_scroll.py
Normal file
@ -0,0 +1,141 @@
|
||||
# coding:utf-8
|
||||
from collections import deque
|
||||
from enum import Enum
|
||||
from math import cos, pi, ceil
|
||||
|
||||
from PySide6.QtCore import QDateTime, Qt, QTimer, QPoint
|
||||
from PySide6.QtGui import QWheelEvent
|
||||
from PySide6.QtWidgets import QApplication, QScrollArea, QAbstractScrollArea
|
||||
|
||||
|
||||
class SmoothScroll:
|
||||
""" Scroll smoothly """
|
||||
|
||||
def __init__(self, widget: QScrollArea, orient=Qt.Vertical):
|
||||
"""
|
||||
Parameters
|
||||
----------
|
||||
widget: QScrollArea
|
||||
scroll area to scroll smoothly
|
||||
|
||||
orient: Orientation
|
||||
scroll orientation
|
||||
"""
|
||||
self.widget = widget
|
||||
self.orient = orient
|
||||
self.fps = 60
|
||||
self.duration = 400
|
||||
self.stepsTotal = 0
|
||||
self.stepRatio = 1.5
|
||||
self.acceleration = 1
|
||||
self.lastWheelEvent = None
|
||||
self.scrollStamps = deque()
|
||||
self.stepsLeftQueue = deque()
|
||||
self.smoothMoveTimer = QTimer(widget)
|
||||
self.smoothMode = SmoothMode(SmoothMode.LINEAR)
|
||||
self.smoothMoveTimer.timeout.connect(self.__smoothMove)
|
||||
|
||||
def setSmoothMode(self, smoothMode):
|
||||
""" set smooth mode """
|
||||
self.smoothMode = smoothMode
|
||||
|
||||
def wheelEvent(self, e):
|
||||
# only process the wheel events triggered by mouse, fixes issue #75
|
||||
delta = e.angleDelta().y() if e.angleDelta().y() != 0 else e.angleDelta().x()
|
||||
if self.smoothMode == SmoothMode.NO_SMOOTH or abs(delta) % 120 != 0:
|
||||
QAbstractScrollArea.wheelEvent(self.widget, e)
|
||||
return
|
||||
|
||||
# push current time to queque
|
||||
now = QDateTime.currentDateTime().toMSecsSinceEpoch()
|
||||
self.scrollStamps.append(now)
|
||||
while now - self.scrollStamps[0] > 500:
|
||||
self.scrollStamps.popleft()
|
||||
|
||||
# adjust the acceration ratio based on unprocessed events
|
||||
accerationRatio = min(len(self.scrollStamps) / 15, 1)
|
||||
self.lastWheelPos = e.position()
|
||||
self.lastWheelGlobalPos = e.globalPosition()
|
||||
|
||||
# get the number of steps
|
||||
self.stepsTotal = self.fps * self.duration / 1000
|
||||
|
||||
# get the moving distance corresponding to each event
|
||||
delta = delta* self.stepRatio
|
||||
if self.acceleration > 0:
|
||||
delta += delta * self.acceleration * accerationRatio
|
||||
|
||||
# form a list of moving distances and steps, and insert it into the queue for processing.
|
||||
self.stepsLeftQueue.append([delta, self.stepsTotal])
|
||||
|
||||
# overflow time of timer: 1000ms/frames
|
||||
self.smoothMoveTimer.start(int(1000 / self.fps))
|
||||
|
||||
def __smoothMove(self):
|
||||
""" scroll smoothly when timer time out """
|
||||
totalDelta = 0
|
||||
|
||||
# Calculate the scrolling distance of all unprocessed events,
|
||||
# the timer will reduce the number of steps by 1 each time it overflows.
|
||||
for i in self.stepsLeftQueue:
|
||||
totalDelta += self.__subDelta(i[0], i[1])
|
||||
i[1] -= 1
|
||||
|
||||
# If the event has been processed, move it out of the queue
|
||||
while self.stepsLeftQueue and self.stepsLeftQueue[0][1] == 0:
|
||||
self.stepsLeftQueue.popleft()
|
||||
|
||||
# construct wheel event
|
||||
if self.orient == Qt.Vertical:
|
||||
pixelDelta = QPoint(round(totalDelta), 0)
|
||||
bar = self.widget.verticalScrollBar()
|
||||
else:
|
||||
pixelDelta = QPoint(0, round(totalDelta))
|
||||
bar = self.widget.horizontalScrollBar()
|
||||
|
||||
e = QWheelEvent(
|
||||
self.lastWheelPos,
|
||||
self.lastWheelGlobalPos,
|
||||
pixelDelta,
|
||||
QPoint(round(totalDelta), 0),
|
||||
Qt.MouseButton.LeftButton,
|
||||
Qt.KeyboardModifier.NoModifier,
|
||||
Qt.ScrollPhase.ScrollBegin,
|
||||
False,
|
||||
)
|
||||
|
||||
# send wheel event to app
|
||||
QApplication.sendEvent(bar, e)
|
||||
|
||||
# stop scrolling if the queque is empty
|
||||
if not self.stepsLeftQueue:
|
||||
self.smoothMoveTimer.stop()
|
||||
|
||||
def __subDelta(self, delta, stepsLeft):
|
||||
""" get the interpolation for each step """
|
||||
m = self.stepsTotal / 2
|
||||
x = abs(self.stepsTotal - stepsLeft - m)
|
||||
|
||||
res = 0
|
||||
if self.smoothMode == SmoothMode.NO_SMOOTH:
|
||||
res = 0
|
||||
elif self.smoothMode == SmoothMode.CONSTANT:
|
||||
res = delta / self.stepsTotal
|
||||
elif self.smoothMode == SmoothMode.LINEAR:
|
||||
res = 2 * delta / self.stepsTotal * (m - x) / m
|
||||
elif self.smoothMode == SmoothMode.QUADRATI:
|
||||
res = 3 / 4 / m * (1 - x * x / m / m) * delta
|
||||
elif self.smoothMode == SmoothMode.COSINE:
|
||||
res = (cos(x * pi / m) + 1) / (2 * m) * delta
|
||||
|
||||
return res
|
||||
|
||||
|
||||
class SmoothMode(Enum):
|
||||
""" Smooth mode """
|
||||
NO_SMOOTH = 0
|
||||
CONSTANT = 1
|
||||
LINEAR = 2
|
||||
QUADRATI = 3
|
||||
COSINE = 4
|
||||
|
||||
512
qfluentwidgets/common/style_sheet.py
Normal file
512
qfluentwidgets/common/style_sheet.py
Normal file
@ -0,0 +1,512 @@
|
||||
# coding:utf-8
|
||||
from enum import Enum
|
||||
from string import Template
|
||||
from typing import List, Union
|
||||
import weakref
|
||||
|
||||
from PySide6.QtCore import QFile, QObject, QEvent, QDynamicPropertyChangeEvent
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from .config import qconfig, Theme, isDarkTheme
|
||||
|
||||
|
||||
class StyleSheetManager(QObject):
|
||||
""" Style sheet manager """
|
||||
|
||||
def __init__(self):
|
||||
self.widgets = weakref.WeakKeyDictionary()
|
||||
|
||||
def register(self, source, widget: QWidget, reset=True):
|
||||
""" register widget to manager
|
||||
|
||||
Parameters
|
||||
----------
|
||||
source: str | StyleSheetBase
|
||||
qss source, it could be:
|
||||
* `str`: qss file path
|
||||
* `StyleSheetBase`: style sheet instance
|
||||
|
||||
widget: QWidget
|
||||
the widget to set style sheet
|
||||
|
||||
reset: bool
|
||||
whether to reset the qss source
|
||||
"""
|
||||
if isinstance(source, str):
|
||||
source = StyleSheetFile(source)
|
||||
|
||||
if widget not in self.widgets:
|
||||
widget.destroyed.connect(lambda: self.deregister(widget))
|
||||
widget.installEventFilter(CustomStyleSheetWatcher(widget))
|
||||
widget.installEventFilter(DirtyStyleSheetWatcher(widget))
|
||||
self.widgets[widget] = StyleSheetCompose([source, CustomStyleSheet(widget)])
|
||||
|
||||
if not reset:
|
||||
self.source(widget).add(source)
|
||||
else:
|
||||
self.widgets[widget] = StyleSheetCompose([source, CustomStyleSheet(widget)])
|
||||
|
||||
def deregister(self, widget: QWidget):
|
||||
""" deregister widget from manager """
|
||||
if widget not in self.widgets:
|
||||
return
|
||||
|
||||
self.widgets.pop(widget)
|
||||
|
||||
def items(self):
|
||||
return self.widgets.items()
|
||||
|
||||
def source(self, widget: QWidget):
|
||||
""" get the qss source of widget """
|
||||
return self.widgets.get(widget, StyleSheetCompose([]))
|
||||
|
||||
|
||||
styleSheetManager = StyleSheetManager()
|
||||
|
||||
|
||||
class QssTemplate(Template):
|
||||
""" style sheet template """
|
||||
|
||||
delimiter = '--'
|
||||
|
||||
|
||||
def applyThemeColor(qss: str):
|
||||
""" apply theme color to style sheet
|
||||
|
||||
Parameters
|
||||
----------
|
||||
qss: str
|
||||
the style sheet string to apply theme color, the substituted variable
|
||||
should be equal to the value of `ThemeColor` and starts width `--`, i.e `--ThemeColorPrimary`
|
||||
"""
|
||||
template = QssTemplate(qss)
|
||||
mappings = {c.value: c.name() for c in ThemeColor._member_map_.values()}
|
||||
return template.safe_substitute(mappings)
|
||||
|
||||
|
||||
class StyleSheetBase:
|
||||
""" Style sheet base class """
|
||||
|
||||
def path(self, theme=Theme.AUTO):
|
||||
""" get the path of style sheet """
|
||||
raise NotImplementedError
|
||||
|
||||
def content(self, theme=Theme.AUTO):
|
||||
""" get the content of style sheet """
|
||||
return getStyleSheetFromFile(self.path(theme))
|
||||
|
||||
def apply(self, widget: QWidget, theme=Theme.AUTO):
|
||||
""" apply style sheet to widget """
|
||||
setStyleSheet(widget, self, theme)
|
||||
|
||||
|
||||
class FluentStyleSheet(StyleSheetBase, Enum):
|
||||
""" Fluent style sheet """
|
||||
|
||||
MENU = "menu"
|
||||
LABEL = "label"
|
||||
PIVOT = "pivot"
|
||||
BUTTON = "button"
|
||||
DIALOG = "dialog"
|
||||
SLIDER = "slider"
|
||||
INFO_BAR = "info_bar"
|
||||
SPIN_BOX = "spin_box"
|
||||
TAB_VIEW = "tab_view"
|
||||
TOOL_TIP = "tool_tip"
|
||||
CHECK_BOX = "check_box"
|
||||
COMBO_BOX = "combo_box"
|
||||
FLIP_VIEW = "flip_view"
|
||||
LINE_EDIT = "line_edit"
|
||||
LIST_VIEW = "list_view"
|
||||
TREE_VIEW = "tree_view"
|
||||
INFO_BADGE = "info_badge"
|
||||
PIPS_PAGER = "pips_pager"
|
||||
TABLE_VIEW = "table_view"
|
||||
CARD_WIDGET = "card_widget"
|
||||
TIME_PICKER = "time_picker"
|
||||
COLOR_DIALOG = "color_dialog"
|
||||
MEDIA_PLAYER = "media_player"
|
||||
SETTING_CARD = "setting_card"
|
||||
TEACHING_TIP = "teaching_tip"
|
||||
FLUENT_WINDOW = "fluent_window"
|
||||
SWITCH_BUTTON = "switch_button"
|
||||
MESSAGE_DIALOG = "message_dialog"
|
||||
STATE_TOOL_TIP = "state_tool_tip"
|
||||
CALENDAR_PICKER = "calendar_picker"
|
||||
FOLDER_LIST_DIALOG = "folder_list_dialog"
|
||||
SETTING_CARD_GROUP = "setting_card_group"
|
||||
EXPAND_SETTING_CARD = "expand_setting_card"
|
||||
NAVIGATION_INTERFACE = "navigation_interface"
|
||||
|
||||
def path(self, theme=Theme.AUTO):
|
||||
theme = qconfig.theme if theme == Theme.AUTO else theme
|
||||
return f":/qfluentwidgets/qss/{theme.value.lower()}/{self.value}.qss"
|
||||
|
||||
|
||||
class StyleSheetFile(StyleSheetBase):
|
||||
""" Style sheet file """
|
||||
|
||||
def __init__(self, path: str):
|
||||
super().__init__()
|
||||
self.filePath = path
|
||||
|
||||
def path(self, theme=Theme.AUTO):
|
||||
return self.filePath
|
||||
|
||||
|
||||
class CustomStyleSheet(StyleSheetBase):
|
||||
""" Custom style sheet """
|
||||
|
||||
DARK_QSS_KEY = 'darkCustomQss'
|
||||
LIGHT_QSS_KEY = 'lightCustomQss'
|
||||
|
||||
def __init__(self, widget: QWidget) -> None:
|
||||
super().__init__()
|
||||
self._widget = weakref.ref(widget)
|
||||
|
||||
def path(self, theme=Theme.AUTO):
|
||||
return ''
|
||||
|
||||
@property
|
||||
def widget(self):
|
||||
return self._widget()
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, CustomStyleSheet):
|
||||
return False
|
||||
|
||||
return other.widget is self.widget
|
||||
|
||||
def setCustomStyleSheet(self, lightQss: str, darkQss: str):
|
||||
""" set custom style sheet in light and dark theme mode """
|
||||
self.setLightStyleSheet(lightQss)
|
||||
self.setDarkStyleSheet(darkQss)
|
||||
return self
|
||||
|
||||
def setLightStyleSheet(self, qss: str):
|
||||
""" set the style sheet in light mode """
|
||||
if self.widget:
|
||||
self.widget.setProperty(self.LIGHT_QSS_KEY, qss)
|
||||
|
||||
return self
|
||||
|
||||
def setDarkStyleSheet(self, qss: str):
|
||||
""" set the style sheet in dark mode """
|
||||
if self.widget:
|
||||
self.widget.setProperty(self.DARK_QSS_KEY, qss)
|
||||
|
||||
return self
|
||||
|
||||
def lightStyleSheet(self) -> str:
|
||||
if not self.widget:
|
||||
return ''
|
||||
|
||||
return self.widget.property(self.LIGHT_QSS_KEY) or ''
|
||||
|
||||
def darkStyleSheet(self) -> str:
|
||||
if not self.widget:
|
||||
return ''
|
||||
|
||||
return self.widget.property(self.DARK_QSS_KEY) or ''
|
||||
|
||||
def content(self, theme=Theme.AUTO) -> str:
|
||||
theme = qconfig.theme if theme == Theme.AUTO else theme
|
||||
|
||||
if theme == Theme.LIGHT:
|
||||
return self.lightStyleSheet()
|
||||
|
||||
return self.darkStyleSheet()
|
||||
|
||||
|
||||
class CustomStyleSheetWatcher(QObject):
|
||||
""" Custom style sheet watcher """
|
||||
|
||||
def eventFilter(self, obj: QWidget, e: QEvent):
|
||||
if e.type() != QEvent.DynamicPropertyChange:
|
||||
return super().eventFilter(obj, e)
|
||||
|
||||
name = QDynamicPropertyChangeEvent(e).propertyName().data().decode()
|
||||
if name in [CustomStyleSheet.LIGHT_QSS_KEY, CustomStyleSheet.DARK_QSS_KEY]:
|
||||
addStyleSheet(obj, CustomStyleSheet(obj))
|
||||
|
||||
return super().eventFilter(obj, e)
|
||||
|
||||
|
||||
class DirtyStyleSheetWatcher(QObject):
|
||||
""" Dirty style sheet watcher """
|
||||
|
||||
def eventFilter(self, obj: QWidget, e: QEvent):
|
||||
if e.type() != QEvent.Type.Paint or not obj.property('dirty-qss'):
|
||||
return super().eventFilter(obj, e)
|
||||
|
||||
obj.setProperty('dirty-qss', False)
|
||||
if obj in styleSheetManager.widgets:
|
||||
obj.setStyleSheet(getStyleSheet(styleSheetManager.source(obj)))
|
||||
|
||||
return super().eventFilter(obj, e)
|
||||
|
||||
|
||||
class StyleSheetCompose(StyleSheetBase):
|
||||
""" Style sheet compose """
|
||||
|
||||
def __init__(self, sources: List[StyleSheetBase]):
|
||||
super().__init__()
|
||||
self.sources = sources
|
||||
|
||||
def content(self, theme=Theme.AUTO):
|
||||
return '\n'.join([i.content(theme) for i in self.sources])
|
||||
|
||||
def add(self, source: StyleSheetBase):
|
||||
""" add style sheet source """
|
||||
if source is self or source in self.sources:
|
||||
return
|
||||
|
||||
self.sources.append(source)
|
||||
|
||||
def remove(self, source: StyleSheetBase):
|
||||
""" remove style sheet source """
|
||||
if source not in self.sources:
|
||||
return
|
||||
|
||||
self.sources.remove(source)
|
||||
|
||||
|
||||
def getStyleSheetFromFile(file: Union[str, QFile]):
|
||||
""" get style sheet from qss file """
|
||||
f = QFile(file)
|
||||
f.open(QFile.ReadOnly)
|
||||
qss = str(f.readAll(), encoding='utf-8')
|
||||
f.close()
|
||||
return qss
|
||||
|
||||
|
||||
def getStyleSheet(source: Union[str, StyleSheetBase], theme=Theme.AUTO):
|
||||
""" get style sheet
|
||||
|
||||
Parameters
|
||||
----------
|
||||
source: str | StyleSheetBase
|
||||
qss source, it could be:
|
||||
* `str`: qss file path
|
||||
* `StyleSheetBase`: style sheet instance
|
||||
|
||||
theme: Theme
|
||||
the theme of style sheet
|
||||
"""
|
||||
if isinstance(source, str):
|
||||
source = StyleSheetFile(source)
|
||||
|
||||
return applyThemeColor(source.content(theme))
|
||||
|
||||
|
||||
def setStyleSheet(widget: QWidget, source: Union[str, StyleSheetBase], theme=Theme.AUTO, register=True):
|
||||
""" set the style sheet of widget
|
||||
|
||||
Parameters
|
||||
----------
|
||||
widget: QWidget
|
||||
the widget to set style sheet
|
||||
|
||||
source: str | StyleSheetBase
|
||||
qss source, it could be:
|
||||
* `str`: qss file path
|
||||
* `StyleSheetBase`: style sheet instance
|
||||
|
||||
theme: Theme
|
||||
the theme of style sheet
|
||||
|
||||
register: bool
|
||||
whether to register the widget to the style manager. If `register=True`, the style of
|
||||
the widget will be updated automatically when the theme changes
|
||||
"""
|
||||
if register:
|
||||
styleSheetManager.register(source, widget)
|
||||
|
||||
widget.setStyleSheet(getStyleSheet(source, theme))
|
||||
|
||||
|
||||
def setCustomStyleSheet(widget: QWidget, lightQss: str, darkQss: str):
|
||||
""" set custom style sheet
|
||||
|
||||
Parameters
|
||||
----------
|
||||
widget: QWidget
|
||||
the widget to add style sheet
|
||||
|
||||
lightQss: str
|
||||
style sheet used in light theme mode
|
||||
|
||||
darkQss: str
|
||||
style sheet used in light theme mode
|
||||
"""
|
||||
CustomStyleSheet(widget).setCustomStyleSheet(lightQss, darkQss)
|
||||
|
||||
|
||||
def addStyleSheet(widget: QWidget, source: Union[str, StyleSheetBase], theme=Theme.AUTO, register=True):
|
||||
""" add style sheet to widget
|
||||
|
||||
Parameters
|
||||
----------
|
||||
widget: QWidget
|
||||
the widget to set style sheet
|
||||
|
||||
source: str | StyleSheetBase
|
||||
qss source, it could be:
|
||||
* `str`: qss file path
|
||||
* `StyleSheetBase`: style sheet instance
|
||||
|
||||
theme: Theme
|
||||
the theme of style sheet
|
||||
|
||||
register: bool
|
||||
whether to register the widget to the style manager. If `register=True`, the style of
|
||||
the widget will be updated automatically when the theme changes
|
||||
"""
|
||||
if register:
|
||||
styleSheetManager.register(source, widget, reset=False)
|
||||
qss = getStyleSheet(styleSheetManager.source(widget), theme)
|
||||
else:
|
||||
qss = widget.styleSheet() + '\n' + getStyleSheet(source, theme)
|
||||
|
||||
if qss.rstrip() != widget.styleSheet().rstrip():
|
||||
widget.setStyleSheet(qss)
|
||||
|
||||
|
||||
def updateStyleSheet(lazy=False):
|
||||
""" update the style sheet of all fluent widgets
|
||||
|
||||
Parameters
|
||||
----------
|
||||
lazy: bool
|
||||
whether to update the style sheet lazily, set to `True` will accelerate theme switching
|
||||
"""
|
||||
removes = []
|
||||
for widget, file in styleSheetManager.items():
|
||||
try:
|
||||
if not (lazy and widget.visibleRegion().isNull()):
|
||||
setStyleSheet(widget, file, qconfig.theme)
|
||||
else:
|
||||
styleSheetManager.register(file, widget)
|
||||
widget.setProperty('dirty-qss', True)
|
||||
except RuntimeError:
|
||||
removes.append(widget)
|
||||
|
||||
for widget in removes:
|
||||
styleSheetManager.deregister(widget)
|
||||
|
||||
|
||||
def setTheme(theme: Theme, save=False, lazy=False):
|
||||
""" set the theme of application
|
||||
|
||||
Parameters
|
||||
----------
|
||||
theme: Theme
|
||||
theme mode
|
||||
|
||||
save: bool
|
||||
whether to save the change to config file
|
||||
|
||||
lazy: bool
|
||||
whether to update the style sheet lazily, set to `True` will accelerate theme switching
|
||||
"""
|
||||
qconfig.set(qconfig.themeMode, theme, save)
|
||||
updateStyleSheet(lazy)
|
||||
qconfig.themeChangedFinished.emit()
|
||||
|
||||
|
||||
def toggleTheme(save=False, lazy=False):
|
||||
""" toggle the theme of application
|
||||
|
||||
Parameters
|
||||
----------
|
||||
save: bool
|
||||
whether to save the change to config file
|
||||
|
||||
lazy: bool
|
||||
whether to update the style sheet lazily, set to `True` will accelerate theme switching
|
||||
"""
|
||||
theme = Theme.LIGHT if isDarkTheme() else Theme.DARK
|
||||
setTheme(theme, save, lazy)
|
||||
|
||||
|
||||
class ThemeColor(Enum):
|
||||
""" Theme color type """
|
||||
|
||||
PRIMARY = "ThemeColorPrimary"
|
||||
DARK_1 = "ThemeColorDark1"
|
||||
DARK_2 = "ThemeColorDark2"
|
||||
DARK_3 = "ThemeColorDark3"
|
||||
LIGHT_1 = "ThemeColorLight1"
|
||||
LIGHT_2 = "ThemeColorLight2"
|
||||
LIGHT_3 = "ThemeColorLight3"
|
||||
|
||||
def name(self):
|
||||
return self.color().name()
|
||||
|
||||
def color(self):
|
||||
color = qconfig.get(qconfig._cfg.themeColor) # type:QColor
|
||||
|
||||
# transform color into hsv space
|
||||
h, s, v, _ = color.getHsvF()
|
||||
|
||||
if isDarkTheme():
|
||||
s *= 0.84
|
||||
v = 1
|
||||
if self == self.DARK_1:
|
||||
v *= 0.9
|
||||
elif self == self.DARK_2:
|
||||
s *= 0.977
|
||||
v *= 0.82
|
||||
elif self == self.DARK_3:
|
||||
s *= 0.95
|
||||
v *= 0.7
|
||||
elif self == self.LIGHT_1:
|
||||
s *= 0.92
|
||||
elif self == self.LIGHT_2:
|
||||
s *= 0.78
|
||||
elif self == self.LIGHT_3:
|
||||
s *= 0.65
|
||||
else:
|
||||
if self == self.DARK_1:
|
||||
v *= 0.75
|
||||
elif self == self.DARK_2:
|
||||
s *= 1.05
|
||||
v *= 0.5
|
||||
elif self == self.DARK_3:
|
||||
s *= 1.1
|
||||
v *= 0.4
|
||||
elif self == self.LIGHT_1:
|
||||
v *= 1.05
|
||||
elif self == self.LIGHT_2:
|
||||
s *= 0.75
|
||||
v *= 1.05
|
||||
elif self == self.LIGHT_3:
|
||||
s *= 0.65
|
||||
v *= 1.05
|
||||
|
||||
return QColor.fromHsvF(h, min(s, 1), min(v, 1))
|
||||
|
||||
|
||||
def themeColor():
|
||||
""" get theme color """
|
||||
return ThemeColor.PRIMARY.color()
|
||||
|
||||
|
||||
def setThemeColor(color, save=False, lazy=False):
|
||||
""" set theme color
|
||||
|
||||
Parameters
|
||||
----------
|
||||
color: QColor | Qt.GlobalColor | str
|
||||
theme color
|
||||
|
||||
save: bool
|
||||
whether to save to change to config file
|
||||
|
||||
lazy: bool
|
||||
whether to update the style sheet lazily
|
||||
"""
|
||||
color = QColor(color)
|
||||
qconfig.set(qconfig.themeColor, color, save=save)
|
||||
updateStyleSheet(lazy)
|
||||
27
qfluentwidgets/common/theme_listener.py
Normal file
27
qfluentwidgets/common/theme_listener.py
Normal file
@ -0,0 +1,27 @@
|
||||
# coding:utf-8
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from .config import Theme, qconfig
|
||||
import darkdetect
|
||||
|
||||
|
||||
class SystemThemeListener(QThread):
|
||||
""" System theme listener """
|
||||
|
||||
systemThemeChanged = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
def run(self):
|
||||
darkdetect.listener(self._onThemeChanged)
|
||||
|
||||
def _onThemeChanged(self, theme: str):
|
||||
theme = Theme.DARK if theme.lower() == "dark" else Theme.LIGHT
|
||||
|
||||
if qconfig.themeMode.value != Theme.AUTO or theme == qconfig.theme:
|
||||
return
|
||||
|
||||
qconfig.theme = Theme.AUTO
|
||||
qconfig._cfg.themeChanged.emit(Theme.AUTO)
|
||||
self.systemThemeChanged.emit()
|
||||
14
qfluentwidgets/common/translator.py
Normal file
14
qfluentwidgets/common/translator.py
Normal file
@ -0,0 +1,14 @@
|
||||
# coding: utf-8
|
||||
from PySide6.QtCore import QTranslator, QLocale
|
||||
|
||||
|
||||
class FluentTranslator(QTranslator):
|
||||
""" Translator of fluent widgets """
|
||||
|
||||
def __init__(self, locale: QLocale = None, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.load(locale or QLocale())
|
||||
|
||||
def load(self, locale: QLocale):
|
||||
""" load translation file """
|
||||
super().load(f":/qfluentwidgets/i18n/qfluentwidgets.{locale.name()}.qm")
|
||||
Reference in New Issue
Block a user