Unify application theme design system

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:20:16 +08:00
parent 0c6d637300
commit 550ca528d1
4 changed files with 298 additions and 85 deletions
+142 -14
View File
@@ -33,6 +33,7 @@ class AppStyleSheet:
Palettes = {
Light: {
# Application surfaces and interaction states.
'window': '#F4F7FB',
'panel': '#FFFFFF',
'panel_alt': '#F8FAFD',
@@ -64,8 +65,37 @@ class AppStyleSheet:
'warning_soft': '#FFF4D6',
'scroll_handle': '#B9C5D4',
'scroll_handle_hover': '#93A3B7',
# Editor semantics.
'editor_background': '#F8FAFC',
'editor_text': '#172033',
'editor_keyword': '#7C3AED',
'editor_string': '#0F766E',
'editor_number': '#B45309',
'editor_comment': '#64748B',
'editor_ip': '#0369A1',
'editor_url': '#2563EB',
'editor_warning': '#A16207',
'editor_error': '#D33F5A',
'editor_symbol': '#475569',
'editor_key': '#047857',
'editor_timestamp': '#64748B',
'editor_logger': '#C2410C',
'editor_info': '#15803D',
'editor_debug': '#2563EB',
'editor_critical': '#B91C1C',
'editor_selection': '#D8E8FF',
'editor_selection_text': '#102A56',
# Progress and connection semantics.
'progress_background': '#DCE5F0',
'progress_chunk_start': '#2563EB',
'progress_chunk_end': '#22B8CF',
'progress_text': '#172033',
'connection_disconnected': '#D94F64',
'connection_connecting': '#3B82F6',
'connection_connected': '#159A70',
},
Dark: {
# Application surfaces and interaction states.
'window': '#0F131A',
'panel': '#151A22',
'panel_alt': '#11161D',
@@ -97,6 +127,34 @@ class AppStyleSheet:
'warning_soft': '#352C18',
'scroll_handle': '#3B4654',
'scroll_handle_hover': '#536171',
# Editor semantics.
'editor_background': '#10151D',
'editor_text': '#E7ECF4',
'editor_keyword': '#C4A7FF',
'editor_string': '#85E0B7',
'editor_number': '#F5B971',
'editor_comment': '#8290A4',
'editor_ip': '#80D8FF',
'editor_url': '#8AB4FF',
'editor_warning': '#F7C65F',
'editor_error': '#FF7A8A',
'editor_symbol': '#C7D0DD',
'editor_key': '#6EE7B7',
'editor_timestamp': '#8290A4',
'editor_logger': '#FFAD7A',
'editor_info': '#7DD3A8',
'editor_debug': '#82B4FF',
'editor_critical': '#FF5C70',
'editor_selection': '#214A7D',
'editor_selection_text': '#F6F9FF',
# Progress and connection semantics.
'progress_background': '#242C38',
'progress_chunk_start': '#5B9BFF',
'progress_chunk_end': '#36D6C5',
'progress_text': '#F7F9FC',
'connection_disconnected': '#FF6B7A',
'connection_connecting': '#5B9BFF',
'connection_connected': '#42D39A',
},
}
@@ -108,11 +166,93 @@ class AppStyleSheet:
return AppStyleSheet.Light
@staticmethod
def paletteForTheme(theme):
"""Return the semantic token palette for *theme*."""
return AppStyleSheet.Palettes[AppStyleSheet.normalizeTheme(theme)]
@staticmethod
def editorStyleSheet(widgetName, fontFamily='', theme=Light):
"""Return editor chrome built exclusively from semantic theme tokens."""
palette = AppStyleSheet.paletteForTheme(theme)
escapedFontFamily = fontFamily.replace('\\', '\\\\').replace("'", "\\'")
fontDeclaration = ''
if escapedFontFamily:
fontDeclaration = f"font-family: '{escapedFontFamily}';"
return dedent(f"""
{widgetName} {{
border: 1px solid {palette['border']};
border-radius: 6px;
background-color: {palette['editor_background']};
color: {palette['editor_text']};
selection-background-color: {palette['editor_selection']};
selection-color: {palette['editor_selection_text']};
{fontDeclaration}
}}
{widgetName}:hover {{
border-color: {palette['border_strong']};
}}
{widgetName}:focus {{
border-color: {palette['accent']};
background-color: {palette['editor_background']};
}}
""").strip()
@staticmethod
def progressBarStyleSheet(theme=Light):
"""Return progress styling built from progress and connection tokens."""
palette = AppStyleSheet.paletteForTheme(theme)
return dedent(f"""
QProgressBar {{
min-height: 12px;
border: 1px solid {palette['border']};
border-radius: 6px;
background-color: {palette['progress_background']};
color: {palette['progress_text']};
text-align: center;
}}
QProgressBar::chunk {{
border-radius: 5px;
background: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 0,
stop: 0 {palette['progress_chunk_start']},
stop: 1 {palette['progress_chunk_end']}
);
}}
QProgressBar[connectionState="disconnected"]::chunk {{
background: {palette['connection_disconnected']};
}}
QProgressBar[connectionState="connecting"]::chunk {{
background: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 0,
stop: 0 {palette['connection_connecting']},
stop: 1 {palette['progress_chunk_end']}
);
}}
QProgressBar[connectionState="connected"]::chunk {{
background: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 0,
stop: 0 {palette['progress_chunk_start']},
stop: 1 {palette['connection_connected']}
);
}}
""").strip()
@staticmethod
def forTheme(theme):
"""Return the complete Qt style sheet for *theme*."""
normalizedTheme = AppStyleSheet.normalizeTheme(theme)
palette = AppStyleSheet.Palettes[normalizedTheme]
palette = AppStyleSheet.paletteForTheme(normalizedTheme)
progressBarStyleSheet = AppStyleSheet.progressBarStyleSheet(normalizedTheme)
iconPrefix = ':/Icons/bootstrap'
if normalizedTheme == AppStyleSheet.Dark:
@@ -689,19 +829,7 @@ class AppStyleSheet:
background-color: transparent;
}}
QProgressBar {{
min-height: 8px;
border: none;
border-radius: 4px;
background-color: {palette['raised']};
color: {palette['text']};
text-align: center;
}}
QProgressBar::chunk {{
border-radius: 4px;
background-color: {palette['accent']};
}}
{progressBarStyleSheet}
QCheckBox,
QRadioButton {{
+28 -8
View File
@@ -287,12 +287,12 @@ class AppQTextBrowser(SupportPointSizeSettings, QTextBrowser):
AppSettings.set(self.pointSizeSettingsName, str(self.font().pointSize()))
class DraculaTextEditor(AppQPlainTextEdit):
"""Represent dracula text editor."""
class DraculaTextEditor(Mixins.ThemeAware, AppQPlainTextEdit):
"""Represent the legacy-named, application-themed text editor."""
def __init__(self, *args, **kwargs):
"""Initialize the DraculaTextEditor."""
fontFamily = kwargs.pop('fontFamily', '')
self._fontFamily = kwargs.pop('fontFamily', '')
super().__init__(*args, **kwargs)
@@ -302,7 +302,7 @@ class DraculaTextEditor(AppQPlainTextEdit):
# Theme
self.setStyleSheet(
DraculaEditorTheme.getStyleSheet(
widgetName='QPlainTextEdit', fontFamily=fontFamily
widgetName='QPlainTextEdit', fontFamily=self._fontFamily
)
)
@@ -324,6 +324,16 @@ class DraculaTextEditor(AppQPlainTextEdit):
self.modificationChanged.connect(handleModificationChanged)
self.cursorPositionChanged.connect(handleCursorPositionChanged)
def themeChangedCallback(self, theme: str):
"""Refresh editor chrome after an application theme change."""
self.setStyleSheet(
DraculaEditorTheme.getStyleSheet(
widgetName='QPlainTextEdit',
fontFamily=self._fontFamily,
theme=theme,
)
)
def registerCursorPositionChangedCb(self, callback: Callable[[QTextCursor], None]):
"""Handle register cursor position changed cb for the dracula text editor."""
self._cursorPositionChangedCb = callback
@@ -343,12 +353,12 @@ class DraculaJSONTextEditor(DraculaTextEditor):
self._syntaxHighlighter = DraculaJSONSyntaxHighlighter(self.document())
class DraculaTextBrowser(AppQTextBrowser):
"""Represent dracula text browser."""
class DraculaTextBrowser(Mixins.ThemeAware, AppQTextBrowser):
"""Represent the legacy-named, application-themed text browser."""
def __init__(self, *args, **kwargs):
"""Initialize the DraculaTextBrowser."""
fontFamily = kwargs.pop('fontFamily', '')
self._fontFamily = kwargs.pop('fontFamily', '')
super().__init__(*args, **kwargs)
@@ -357,6 +367,16 @@ class DraculaTextBrowser(AppQTextBrowser):
# Theme
self.setStyleSheet(
DraculaEditorTheme.getStyleSheet(
widgetName='QTextBrowser', fontFamily=fontFamily
widgetName='QTextBrowser', fontFamily=self._fontFamily
)
)
def themeChangedCallback(self, theme: str):
"""Refresh browser chrome after an application theme change."""
self.setStyleSheet(
DraculaEditorTheme.getStyleSheet(
widgetName='QTextBrowser',
fontFamily=self._fontFamily,
theme=theme,
)
)
+101 -43
View File
@@ -20,10 +20,13 @@
from __future__ import annotations
from Furious.Core.Tun2socks import Tun2socks
from Furious.Frozenlib.Mixins import Mixins
from Furious.Plugins import getPluginRegistry
from Furious.Qt.AppStyleSheet import AppStyleSheet
from PySide6 import QtCore
from PySide6.QtGui import *
from PySide6.QtWidgets import QApplication
__all__ = [
'DraculaEditorTheme',
@@ -32,6 +35,23 @@ __all__ = [
]
def _currentTheme():
"""Return the application's active theme without coupling to its concrete type."""
app = QApplication.instance()
if app is not None:
themeGetter = getattr(app, 'theme', None)
if callable(themeGetter):
try:
return AppStyleSheet.normalizeTheme(themeGetter())
except Exception:
# A partially initialized application falls back to the safe default.
pass
return AppStyleSheet.Light
class EditorHighlightRules:
"""Represent editor highlight rules."""
@@ -66,33 +86,51 @@ class EditorTheme:
class DraculaEditorTheme(EditorTheme):
"""Represent dracula editor theme."""
"""Compatibility facade for the application-owned editor theme."""
def __init__(self, *args, **kwargs):
"""Initialize the DraculaEditorTheme."""
super().__init__(*args, **kwargs)
@staticmethod
def getStyleSheet(widgetName, fontFamily):
"""Return style sheet."""
return (
f'{widgetName} {{'
f' background-color: #282A36;'
f' color: #F8F8F2;'
f' font-family: \'{fontFamily}\';'
f'}}'
def getStyleSheet(widgetName, fontFamily, theme=None):
"""Return token-driven editor styling while preserving the legacy API."""
if theme is None:
theme = _currentTheme()
return AppStyleSheet.editorStyleSheet(
widgetName=widgetName,
fontFamily=fontFamily,
theme=theme,
)
class AppQSyntaxHighlighter(QSyntaxHighlighter):
class AppQSyntaxHighlighter(Mixins.ThemeAware, QSyntaxHighlighter):
"""Apply syntax highlighting for app q syntax text."""
def __init__(self, *args, **kwargs):
"""Initialize the AppQSyntaxHighlighter."""
self._theme = AppStyleSheet.normalizeTheme(kwargs.pop('theme', _currentTheme()))
super().__init__(*args, **kwargs)
self.highlightRules = list()
def buildHighlightRules(self, palette):
"""Return highlight rules for *palette*."""
return list()
def applyTheme(self, theme):
"""Rebuild formats from semantic tokens and rehighlight the document."""
self._theme = AppStyleSheet.normalizeTheme(theme)
palette = AppStyleSheet.paletteForTheme(self._theme)
self.highlightRules = self.buildHighlightRules(palette)
self.rehighlight()
def themeChangedCallback(self, theme: str):
"""Refresh syntax formats after an application theme change."""
self.applyTheme(theme)
def highlightBlock(self, text):
"""Handle highlight block for the app q syntax highlighter."""
for highlightRule in self.highlightRules:
@@ -126,33 +164,38 @@ class AppQSyntaxHighlighter(QSyntaxHighlighter):
class DraculaJSONSyntaxHighlighter(AppQSyntaxHighlighter):
"""Apply syntax highlighting for dracula JSON syntax text."""
"""Apply application-themed JSON syntax highlighting."""
def __init__(self, *args, **kwargs):
"""Initialize the DraculaJSONSyntaxHighlighter."""
super().__init__(*args, **kwargs)
self.highlightRules = [
# Keywords: true, false, null. Pink. Bold
EditorHighlightRules(r'\b(true|false|null)\b', '#FF79C6', isBold=True),
# Numbers. Purple
EditorHighlightRules(r'[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?', '#BD93F9'),
# Symbols: :, [, ], {, }. White
EditorHighlightRules(r'[:,\[\]\{\}]', '#F8F8F2'),
# Double-quoted strings. Yellow
EditorHighlightRules(r'"[^"\\]*(\\.[^"\\]*)*"', '#F1FA8C'),
# JSON keys. Green
self.applyTheme(self._theme)
def buildHighlightRules(self, palette):
"""Return JSON rules resolved from editor semantic tokens."""
return [
EditorHighlightRules(
r'"([^"\\]*(\\.[^"\\]*)*)"\s*:', '#50FA7B', isJSONKey=True
r'\b(true|false|null)\b', palette['editor_keyword'], isBold=True
),
# Comments(only for hints on display). Grey
EditorHighlightRules(r'^#.*', '#6272A4'),
EditorHighlightRules(
r'[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?',
palette['editor_number'],
),
EditorHighlightRules(r'[:,\[\]\{\}]', palette['editor_symbol']),
EditorHighlightRules(r'"[^"\\]*(\\.[^"\\]*)*"', palette['editor_string']),
EditorHighlightRules(
r'"([^"\\]*(\\.[^"\\]*)*)"\s*:',
palette['editor_key'],
isJSONKey=True,
),
EditorHighlightRules(r'^#.*', palette['editor_comment'], isItalic=True),
]
class DraculaLoggerSyntaxHighlighter(AppQSyntaxHighlighter):
# https://ihateregex.io/expr/ip/
"""Apply syntax highlighting for dracula logger syntax text."""
"""Apply application-themed network logger syntax highlighting."""
IPV4_REGEX = (
r'(\b25[0-5]|\b2[0-4][0-9]|\b[01]?[0-9][0-9]?)'
r'(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}(?::\d{1,5})?\b'
@@ -182,17 +225,20 @@ class DraculaLoggerSyntaxHighlighter(AppQSyntaxHighlighter):
"""Initialize the DraculaLoggerSyntaxHighlighter."""
super().__init__(*args, **kwargs)
self.highlightRules = [
# IP addresses (IPv4 & IPv6)
self.applyTheme(self._theme)
def buildHighlightRules(self, palette):
"""Return network-log rules resolved from editor semantic tokens."""
return [
EditorHighlightRules(
DraculaLoggerSyntaxHighlighter.IPV4_REGEX
+ r'|'
+ DraculaLoggerSyntaxHighlighter.IPV6_REGEX,
'#B4FFFF',
palette['editor_ip'],
),
EditorHighlightRules(
r'(https?:)?\/\/[^\s,"\'>)\]]+', palette['editor_url']
),
# URLs
EditorHighlightRules(r'(https?:)?\/\/[^\s,"\'>)\]]+', '#FFDD88'),
# Timestamps
EditorHighlightRules(
# Application logging timestamp
r'\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}\]' + r'|'
@@ -200,23 +246,35 @@ class DraculaLoggerSyntaxHighlighter(AppQSyntaxHighlighter):
+ r'|'.join(getPluginRegistry().logTimestampPatterns()) + r'|'
# tun2socks logging timestamp
+ r'\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}',
'#7F7F7F',
palette['editor_timestamp'],
),
# Logger name: [X.Y.Z]
EditorHighlightRules(
r'\[[A-Za-z0-9_]+\.[A-Za-z0-9_]+\.[A-Za-z0-9_]+\]', '#FFB46E'
r'\[[A-Za-z0-9_]+\.[A-Za-z0-9_]+\.[A-Za-z0-9_]+\]',
palette['editor_logger'],
),
# Log levels
EditorHighlightRules(r'\[INFO\]|\[Info\]|INFO', '#A0C882', isBold=True),
EditorHighlightRules(r'\[DEBUG\]|\[Debug\]|DEBUG', '#82B4FF', isBold=True),
EditorHighlightRules(
r'\[WARNING\]|\[Warning\]|WARNING', '#FFD75A', isBold=True
r'\[INFO\]|\[Info\]|INFO', palette['editor_info'], isBold=True
),
EditorHighlightRules(r'\[ERROR\]|\[Error\]|ERROR', '#FF7878', isBold=True),
EditorHighlightRules(
r'\[CRITICAL\]|\[Critical\]|CRITICAL', '#FF5050', isBold=True
r'\[DEBUG\]|\[Debug\]|DEBUG',
palette['editor_debug'],
isBold=True,
),
# Quoted strings (single or double)
EditorHighlightRules(r"'[^']*'", '#A0FFA0'),
EditorHighlightRules(r'"[^"]*"', '#A0FFA0'),
EditorHighlightRules(
r'\[WARNING\]|\[Warning\]|WARNING',
palette['editor_warning'],
isBold=True,
),
EditorHighlightRules(
r'\[ERROR\]|\[Error\]|ERROR',
palette['editor_error'],
isBold=True,
),
EditorHighlightRules(
r'\[CRITICAL\]|\[Critical\]|CRITICAL',
palette['editor_critical'],
isBold=True,
),
EditorHighlightRules(r"'[^']*'", palette['editor_string']),
EditorHighlightRules(r'"[^"]*"', palette['editor_string']),
]
+27 -20
View File
@@ -52,10 +52,25 @@ class AutoUpdateProgressBar(Mixins.ConnectionAware, QProgressBar):
self.timer = QtCore.QTimer()
self.timer.timeout.connect(update)
self.setStyleSheet(self.getStyleSheet())
self._setConnectionState('disconnected')
def _setConnectionState(self, state: str):
"""Expose semantic state to the application-owned progress style."""
if self.property('connectionState') == state:
return
self.setProperty('connectionState', state)
# Qt does not automatically repolish a widget after a dynamic property
# used by a style-sheet selector changes.
style = self.style()
style.unpolish(self)
style.polish(self)
self.update()
def start(self, msec: int):
"""Start the auto update progress bar."""
self._setConnectionState('connecting')
self.timer.start(msec)
def stop(self):
@@ -63,31 +78,23 @@ class AutoUpdateProgressBar(Mixins.ConnectionAware, QProgressBar):
self.timer.stop()
@staticmethod
def getStyleSheet():
"""Return style sheet."""
return (
f'QProgressBar {{'
f' border-radius: 2px;'
f' text-align: center;'
f'}}'
f''
f'QProgressBar::chunk {{'
f' background: qlineargradient('
f' x1: 0, y1: 0, x2: 1, y2: 0,'
f' stop: 0 {AppHue.disconnectedColor()},'
f' stop: 1 {AppHue.connectedColor()}'
f' );'
f' border-radius: 2px;'
f'}}'
)
def getStyleSheet(theme=None):
"""Return centralized progress styling for compatibility callers."""
if theme is None:
try:
theme = APP().theme()
except Exception:
theme = AppStyleSheet.Light
return AppStyleSheet.progressBarStyleSheet(theme)
def disconnectedCallback(self):
"""Update the auto update progress bar for a disconnected state."""
pass
self._setConnectionState('disconnected')
def connectedCallback(self):
"""Update the auto update progress bar for a connected state."""
pass
self._setConnectionState('connected')
class ConnectProgressBar(Mixins.QTranslatable, Mixins.ConnectionAware, QWidget):