diff --git a/Furious/Qt/AppStyleSheet.py b/Furious/Qt/AppStyleSheet.py index 79b9f2a..29b28bf 100644 --- a/Furious/Qt/AppStyleSheet.py +++ b/Furious/Qt/AppStyleSheet.py @@ -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 {{ diff --git a/Furious/Qt/TextEditor.py b/Furious/Qt/TextEditor.py index b24d64c..73b8378 100644 --- a/Furious/Qt/TextEditor.py +++ b/Furious/Qt/TextEditor.py @@ -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, ) ) diff --git a/Furious/Qt/TextEditorTheme.py b/Furious/Qt/TextEditorTheme.py index 1e31653..6f9ad6b 100644 --- a/Furious/Qt/TextEditorTheme.py +++ b/Furious/Qt/TextEditorTheme.py @@ -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']), ] diff --git a/Furious/Widget/ConnectProgressBar.py b/Furious/Widget/ConnectProgressBar.py index 0d02601..d1149da 100644 --- a/Furious/Widget/ConnectProgressBar.py +++ b/Furious/Widget/ConnectProgressBar.py @@ -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):