initial fluent-widgets ui

This commit is contained in:
2025-08-14 18:45:16 +08:00
parent 746e83ab23
commit 4c66886257
1198 changed files with 805339 additions and 0 deletions

View 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

View 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

View 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

View 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)

View 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()

View 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

View 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

View 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)

View 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)

View 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)

View 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()

View 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()

View 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

View 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)

View 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()

View 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")