diff --git a/docs/theme-import.md b/docs/theme-import.md index 2c899d26..28b85937 100644 --- a/docs/theme-import.md +++ b/docs/theme-import.md @@ -3,7 +3,8 @@ Querya can apply a **subset** of VS Code theme JSON / JSONC `colors` to `QueryaWorkbenchTheme`, `QueryaEditorTheme`, and the shadcn `ColorScheme`. -Syntax highlighting (`tokenColors`) is tracked separately (issue #46). +Imported `tokenColors` are persisted with the theme file and applied to SQL/JSON +syntax highlighting via `TokenStyleResolver` → `HighlighterTheme` (issue #46). ## Supported `colors` keys diff --git a/lib/core/editor/highlighter_theme_from_querya.dart b/lib/core/editor/highlighter_theme_from_querya.dart index 07773739..53a854f1 100644 --- a/lib/core/editor/highlighter_theme_from_querya.dart +++ b/lib/core/editor/highlighter_theme_from_querya.dart @@ -1,100 +1,33 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; +import 'package:querya_desktop/core/theme/parser/token_colors_highlighter_config.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; import 'package:querya_desktop/core/theme/querya_editor_theme.dart'; import 'package:syntax_highlight/syntax_highlight.dart'; -/// Builds a [HighlighterTheme] from [QueryaEditorTheme] token colors. -HighlighterTheme highlighterThemeFromQueryaEditor(QueryaEditorTheme editor) { +/// Builds a [HighlighterTheme] from [QueryaEditorTheme] and optional VS Code rules. +HighlighterTheme highlighterThemeFromQueryaEditor( + QueryaEditorTheme editor, { + List tokenColors = const [], +}) { final wrapper = TextStyle( color: editor.foreground, fontFamily: editor.fontFamily, fontSize: editor.fontSize, ); - final config = jsonEncode({ - 'settings': [ - { - 'scope': [ - 'comment', - 'comment.line', - 'comment.block', - ], - 'settings': {'foreground': _hex(editor.comment)}, - }, - { - 'scope': [ - 'keyword', - 'keyword.control', - 'keyword.operator', - 'storage.type', - ], - 'settings': {'foreground': _hex(editor.keyword)}, - }, - { - 'scope': [ - 'string', - 'string.quoted', - 'string.quoted.single', - 'string.quoted.double', - ], - 'settings': {'foreground': _hex(editor.string)}, - }, - { - 'scope': [ - 'constant.numeric', - 'constant.numeric.json', - 'number', - ], - 'settings': {'foreground': _hex(editor.number)}, - }, - { - 'scope': [ - 'support.type.property-name', - 'support.type.property-name.json', - ], - 'settings': {'foreground': _hex(editor.type)}, - }, - { - 'scope': [ - 'constant.language', - 'constant.language.json', - ], - 'settings': {'foreground': _hex(editor.keyword)}, - }, - { - 'scope': ['entity.name.function', 'support.function'], - 'settings': {'foreground': _hex(editor.function)}, - }, - { - 'scope': ['entity.name.type', 'support.type'], - 'settings': {'foreground': _hex(editor.type)}, - }, - { - 'scope': ['constant.language', 'variable.language'], - 'settings': {'foreground': _hex(editor.keyword)}, - }, - { - 'settings': {'foreground': _hex(editor.foreground)}, - }, - ], - }); + final config = tokenColors.isNotEmpty + ? buildHighlighterConfigFromTokenColors(tokenColors, editor.foreground) + : buildDefaultEditorHighlighterConfig(editor); return HighlighterTheme.fromConfiguration(config, wrapper); } -String _hex(Color c) { - final a = (c.a * 255).round().clamp(0, 255); - final r = (c.r * 255).round().clamp(0, 255); - final g = (c.g * 255).round().clamp(0, 255); - final b = (c.b * 255).round().clamp(0, 255); - if (a < 255) { - return '#${r.toRadixString(16).padLeft(2, '0')}' - '${g.toRadixString(16).padLeft(2, '0')}' - '${b.toRadixString(16).padLeft(2, '0')}' - '${a.toRadixString(16).padLeft(2, '0')}'; - } - return '#${r.toRadixString(16).padLeft(2, '0')}' - '${g.toRadixString(16).padLeft(2, '0')}' - '${b.toRadixString(16).padLeft(2, '0')}'; +/// JSON config for isolate/off-thread highlighting. +String highlighterThemeConfigJson( + QueryaEditorTheme editor, { + List tokenColors = const [], +}) { + return tokenColors.isNotEmpty + ? buildHighlighterConfigFromTokenColors(tokenColors, editor.foreground) + : buildDefaultEditorHighlighterConfig(editor); } diff --git a/lib/core/editor/querya_code_editor.dart b/lib/core/editor/querya_code_editor.dart index 4076c042..377de8c1 100644 --- a/lib/core/editor/querya_code_editor.dart +++ b/lib/core/editor/querya_code_editor.dart @@ -63,6 +63,7 @@ class _QueryaCodeEditorState extends State { bool _syncing = false; QueryaEditorTheme? _highlightEditorTheme; QueryaCodeLanguage? _highlightLanguage; + int _highlightTokenColorsHash = 0; material.TextEditingController get _activeController => _highlightController ?? _plainController!; @@ -133,15 +134,18 @@ class _QueryaCodeEditorState extends State { _highlightController = null; _highlightEditorTheme = null; _highlightLanguage = null; + _highlightTokenColorsHash = 0; } void _ensureHighlightController(QueryaTheme queryaTheme) { if (!_useHighlighting) return; final editor = queryaTheme.editor; + final tokenHash = Object.hashAll(queryaTheme.tokenColors); if (_highlightController != null && _highlightEditorTheme == editor && - _highlightLanguage == widget.language) { + _highlightLanguage == widget.language && + _highlightTokenColorsHash == tokenHash) { return; } @@ -157,12 +161,18 @@ class _QueryaCodeEditorState extends State { _highlightController = QueryaHighlightController( text: text, + language: widget.language, lightHighlighter: pair.light, darkHighlighter: pair.dark, + lightThemeConfig: pair.lightThemeConfig, + darkThemeConfig: pair.darkThemeConfig, + grammarJson: pair.grammarJson, + wrapperColor: editor.foreground, ); _ownsHighlightController = external == null; _highlightEditorTheme = editor; _highlightLanguage = widget.language; + _highlightTokenColorsHash = tokenHash; _highlightController!.addListener(_onTextChanged); if (external != null) { @@ -235,6 +245,7 @@ class _QueryaCodeEditorState extends State { oldWidget.enableHighlighting != widget.enableHighlighting) { _highlightEditorTheme = null; _highlightLanguage = null; + _highlightTokenColorsHash = 0; if (_useHighlighting) { _ensureHighlightController(context.queryaTheme); } else { diff --git a/lib/core/editor/querya_highlight_controller.dart b/lib/core/editor/querya_highlight_controller.dart index 88123627..ae92387b 100644 --- a/lib/core/editor/querya_highlight_controller.dart +++ b/lib/core/editor/querya_highlight_controller.dart @@ -1,16 +1,34 @@ import 'package:flutter/material.dart'; +import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:syntax_highlight/syntax_highlight.dart'; +import 'syntax_highlight_isolate.dart'; + /// [TextEditingController] that applies [Highlighter] in [buildTextSpan]. class QueryaHighlightController extends TextEditingController { QueryaHighlightController({ super.text, + required this.language, required this.lightHighlighter, required this.darkHighlighter, + required this.lightThemeConfig, + required this.darkThemeConfig, + required this.grammarJson, + required this.wrapperColor, }); + final QueryaCodeLanguage language; final Highlighter lightHighlighter; final Highlighter darkHighlighter; + final String lightThemeConfig; + final String darkThemeConfig; + final String grammarJson; + final Color wrapperColor; + + TextSpan? _cachedSpan; + String? _cachedText; + Brightness? _cachedBrightness; + int _highlightGeneration = 0; @override TextSpan buildTextSpan({ @@ -18,12 +36,74 @@ class QueryaHighlightController extends TextEditingController { TextStyle? style, required bool withComposing, }) { - final highlighter = Theme.of(context).brightness == Brightness.light + final brightness = Theme.of(context).brightness; + final highlighter = brightness == Brightness.light ? lightHighlighter : darkHighlighter; - return TextSpan( + final themeConfig = brightness == Brightness.light + ? lightThemeConfig + : darkThemeConfig; + + if (text.length < kSyntaxHighlightIsolateThreshold) { + return TextSpan( + style: style, + children: [highlighter.highlight(text)], + ); + } + + if (_cachedText == text && + _cachedBrightness == brightness && + _cachedSpan != null) { + return TextSpan(style: style, children: [_cachedSpan!]); + } + + _scheduleIsolateHighlight( + text: text, + brightness: brightness, + themeConfig: themeConfig, style: style, - children: [highlighter.highlight(text)], ); + + if (_cachedSpan != null && _cachedText == text) { + return TextSpan(style: style, children: [_cachedSpan!]); + } + + return TextSpan(style: style, text: text); + } + + void _scheduleIsolateHighlight({ + required String text, + required Brightness brightness, + required String themeConfig, + required TextStyle? style, + }) { + final generation = ++_highlightGeneration; + final lang = switch (language) { + QueryaCodeLanguage.sql => 'sql', + QueryaCodeLanguage.json => 'json', + QueryaCodeLanguage.plain => 'sql', + }; + + highlightOffMainThread( + SyntaxHighlightJob( + code: text, + language: lang, + themeConfigJson: themeConfig, + grammarJson: grammarJson, + wrapperArgb: wrapperColor.toARGB32(), + ), + ).then((segments) { + if (generation != _highlightGeneration) return; + _cachedSpan = segmentsToTextSpan(segments, baseStyle: style); + _cachedText = text; + _cachedBrightness = brightness; + notifyListeners(); + }); + } + + @override + void dispose() { + _highlightGeneration++; + super.dispose(); } } diff --git a/lib/core/editor/syntax_highlight_isolate.dart b/lib/core/editor/syntax_highlight_isolate.dart new file mode 100644 index 00000000..4e1aa7ed --- /dev/null +++ b/lib/core/editor/syntax_highlight_isolate.dart @@ -0,0 +1,130 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:syntax_highlight/syntax_highlight.dart'; + +/// Minimum buffer size before highlighting runs in a background [compute]. +const int kSyntaxHighlightIsolateThreshold = 8192; + +/// Serializable highlight job for [compute]. +class SyntaxHighlightJob { + const SyntaxHighlightJob({ + required this.code, + required this.language, + required this.themeConfigJson, + required this.grammarJson, + required this.wrapperArgb, + }); + + final String code; + final String language; + final String themeConfigJson; + final String grammarJson; + final int wrapperArgb; +} + +/// Flat text segment returned from isolate (rebuilt as [TextSpan] on UI thread). +class HighlightSegment { + const HighlightSegment({ + required this.text, + this.colorArgb, + this.fontWeightValue, + this.fontStyleIndex, + }); + + final String text; + final int? colorArgb; + final int? fontWeightValue; + final int? fontStyleIndex; +} + +String? _loadedGrammarLanguage; + +/// Top-level entry for [compute]; do not rename (Flutter isolate requirement). +List syntaxHighlightInIsolate(SyntaxHighlightJob job) { + if (_loadedGrammarLanguage != job.language) { + Highlighter.addLanguage(job.language, job.grammarJson); + _loadedGrammarLanguage = job.language; + } + + final theme = HighlighterTheme.fromConfiguration( + job.themeConfigJson, + TextStyle(color: Color(job.wrapperArgb)), + ); + final highlighter = Highlighter(language: job.language, theme: theme); + final span = highlighter.highlight(job.code); + return _flattenSpan(span); +} + +List _flattenSpan(TextSpan span) { + final out = []; + void walk(TextSpan node) { + final style = node.style; + if (node.text != null && node.text!.isNotEmpty) { + out.add( + HighlightSegment( + text: node.text!, + colorArgb: style?.color?.toARGB32(), + fontWeightValue: style?.fontWeight?.value, + fontStyleIndex: style?.fontStyle?.index, + ), + ); + } + if (node.children != null) { + for (final child in node.children!) { + if (child is TextSpan) walk(child); + } + } + } + + walk(span); + return out; +} + +TextSpan segmentsToTextSpan( + List segments, { + TextStyle? baseStyle, +}) { + return TextSpan( + style: baseStyle, + children: [ + for (final s in segments) + TextSpan( + text: s.text, + style: _styleFromSegment(s, baseStyle), + ), + ], + ); +} + +FontWeight? _fontWeightFromValue(int? value) { + if (value == null) return null; + for (final w in FontWeight.values) { + if (w.value == value) return w; + } + return null; +} + +TextStyle? _styleFromSegment(HighlightSegment s, TextStyle? base) { + if (s.colorArgb == null && + s.fontWeightValue == null && + s.fontStyleIndex == null) { + return null; + } + return (base ?? const TextStyle()).copyWith( + color: s.colorArgb != null ? Color(s.colorArgb!) : null, + fontWeight: _fontWeightFromValue(s.fontWeightValue), + fontStyle: s.fontStyleIndex != null + ? FontStyle.values[s.fontStyleIndex!] + : null, + ); +} + +/// Runs [syntaxHighlightInIsolate] off the UI thread when [code] is large. +Future> highlightOffMainThread( + SyntaxHighlightJob job, +) { + if (job.code.length < kSyntaxHighlightIsolateThreshold) { + return Future.value(syntaxHighlightInIsolate(job)); + } + return compute(syntaxHighlightInIsolate, job); +} diff --git a/lib/core/editor/syntax_highlight_service.dart b/lib/core/editor/syntax_highlight_service.dart index a22bd6e0..14a20118 100644 --- a/lib/core/editor/syntax_highlight_service.dart +++ b/lib/core/editor/syntax_highlight_service.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; import 'package:querya_desktop/core/theme/querya_editor_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:syntax_highlight/syntax_highlight.dart'; @@ -9,19 +11,37 @@ import 'querya_code_language.dart'; /// Global syntax highlighter setup for [QueryaCodeEditor]. abstract final class SyntaxHighlightService { static bool _initialized = false; + static String? _sqlGrammarJson; + static String? _jsonGrammarJson; static Future ensureInitialized() async { if (_initialized) return; await Highlighter.initialize(['sql', 'json']); + _sqlGrammarJson = await rootBundle.loadString( + 'packages/syntax_highlight/grammars/sql.json', + ); + _jsonGrammarJson = await rootBundle.loadString( + 'packages/syntax_highlight/grammars/json.json', + ); _initialized = true; } static bool get isInitialized => _initialized; + static String grammarJsonFor(QueryaCodeLanguage language) { + _assertInitialized(); + return switch (language) { + QueryaCodeLanguage.sql => _sqlGrammarJson!, + QueryaCodeLanguage.json => _jsonGrammarJson!, + QueryaCodeLanguage.plain => _sqlGrammarJson!, + }; + } + static Highlighter createHighlighter({ required QueryaCodeLanguage language, required QueryaEditorTheme editorTheme, required Brightness brightness, + List tokenColors = const [], }) { _assertInitialized(); final lang = switch (language) { @@ -29,7 +49,10 @@ abstract final class SyntaxHighlightService { QueryaCodeLanguage.json => 'json', QueryaCodeLanguage.plain => 'sql', }; - final theme = highlighterThemeFromQueryaEditor(editorTheme); + final theme = highlighterThemeFromQueryaEditor( + editorTheme, + tokenColors: tokenColors, + ); return Highlighter(language: lang, theme: theme); } @@ -37,16 +60,31 @@ abstract final class SyntaxHighlightService { required QueryaCodeLanguage language, required QueryaTheme queryaTheme, }) { + final tokenColors = queryaTheme.tokenColors; return HighlighterPair( + language: language, + editorTheme: queryaTheme.editor, + tokenColors: tokenColors, + lightThemeConfig: highlighterThemeConfigJson( + queryaTheme.editor, + tokenColors: tokenColors, + ), + darkThemeConfig: highlighterThemeConfigJson( + queryaTheme.editor, + tokenColors: tokenColors, + ), + grammarJson: grammarJsonFor(language), light: createHighlighter( language: language, editorTheme: queryaTheme.editor, brightness: Brightness.light, + tokenColors: tokenColors, ), dark: createHighlighter( language: language, editorTheme: queryaTheme.editor, brightness: Brightness.dark, + tokenColors: tokenColors, ), ); } @@ -61,11 +99,29 @@ abstract final class SyntaxHighlightService { /// Light/dark highlighters for Material [Theme] brightness switching. class HighlighterPair { - const HighlighterPair({required this.light, required this.dark}); + const HighlighterPair({ + required this.light, + required this.dark, + required this.language, + required this.editorTheme, + required this.tokenColors, + required this.lightThemeConfig, + required this.darkThemeConfig, + required this.grammarJson, + }); final Highlighter light; final Highlighter dark; + final QueryaCodeLanguage language; + final QueryaEditorTheme editorTheme; + final List tokenColors; + final String lightThemeConfig; + final String darkThemeConfig; + final String grammarJson; Highlighter forBrightness(Brightness brightness) => brightness == Brightness.light ? light : dark; + + String themeConfigFor(Brightness brightness) => + brightness == Brightness.light ? lightThemeConfig : darkThemeConfig; } diff --git a/lib/core/theme/parser/apply_token_colors_to_editor.dart b/lib/core/theme/parser/apply_token_colors_to_editor.dart new file mode 100644 index 00000000..de8bcb66 --- /dev/null +++ b/lib/core/theme/parser/apply_token_colors_to_editor.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import '../querya_editor_theme.dart'; +import 'token_style_resolver.dart'; +import 'vscode_theme_manifest.dart'; + +/// Maps common TextMate scopes from [rules] onto [QueryaEditorTheme] fields. +QueryaEditorTheme applyTokenColorsToEditor( + QueryaEditorTheme editor, + List rules, +) { + if (rules.isEmpty) return editor; + + final resolver = TokenStyleResolver( + rules: rules, + defaultStyle: TextStyle(color: editor.foreground), + ); + + Color? colorFor(Iterable scopes) { + for (final scope in scopes) { + final c = resolver.resolve(scope).color; + if (c != null) return c; + } + return null; + } + + return editor.copyWith( + comment: colorFor([ + 'comment', + 'comment.line', + 'comment.block', + ]) ?? + editor.comment, + keyword: colorFor([ + 'keyword', + 'keyword.control', + 'keyword.operator', + 'storage.type', + ]) ?? + editor.keyword, + string: colorFor([ + 'string', + 'string.quoted', + 'string.quoted.double', + 'string.quoted.single', + ]) ?? + editor.string, + number: colorFor([ + 'constant.numeric', + 'constant.numeric.json', + 'number', + ]) ?? + editor.number, + function: colorFor([ + 'entity.name.function', + 'support.function', + ]) ?? + editor.function, + type: colorFor([ + 'entity.name.type', + 'support.type', + 'support.type.property-name', + 'support.type.property-name.json', + ]) ?? + editor.type, + ); +} diff --git a/lib/core/theme/parser/querya_theme_from_vscode.dart b/lib/core/theme/parser/querya_theme_from_vscode.dart index f82551e1..6e029144 100644 --- a/lib/core/theme/parser/querya_theme_from_vscode.dart +++ b/lib/core/theme/parser/querya_theme_from_vscode.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import '../querya_editor_theme.dart'; import '../querya_theme.dart'; import '../querya_workbench_theme.dart'; +import 'apply_token_colors_to_editor.dart'; import 'color_parser.dart'; import 'vscode_color_map.dart'; import 'vscode_theme_manifest.dart'; @@ -128,11 +129,16 @@ QueryaTheme buildQueryaThemeFromVsCodeManifest( colorScheme = colorScheme.copyWith(accent: () => schemeAccent!); } + if (manifest.tokenColors.isNotEmpty) { + editor = applyTokenColorsToEditor(editor, manifest.tokenColors); + } + return QueryaTheme( workbench: workbench, editor: editor, brightness: brightness, colorScheme: colorScheme, + tokenColors: manifest.tokenColors, ); } diff --git a/lib/core/theme/parser/token_colors_codec.dart b/lib/core/theme/parser/token_colors_codec.dart new file mode 100644 index 00000000..0cff6efc --- /dev/null +++ b/lib/core/theme/parser/token_colors_codec.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +import 'vscode_theme_manifest.dart'; + +/// JSON persistence for [TokenColorRule] lists (theme import storage). +List tokenColorRulesFromJson(String source) { + final decoded = jsonDecode(source); + if (decoded is! List) return const []; + final rules = []; + for (final item in decoded) { + if (item is Map) { + final rule = TokenColorRule.tryParse(item); + if (rule != null) rules.add(rule); + } + } + return rules; +} + +String tokenColorRulesToJson(List rules) { + final list = [ + for (final r in rules) + { + 'scope': r.scopes.length == 1 ? r.scopes.single : r.scopes, + 'settings': { + if (r.foreground != null) 'foreground': r.foreground, + if (r.background != null) 'background': r.background, + if (r.fontStyle != null) 'fontStyle': r.fontStyle, + }, + }, + ]; + return jsonEncode(list); +} diff --git a/lib/core/theme/parser/token_colors_highlighter_config.dart b/lib/core/theme/parser/token_colors_highlighter_config.dart new file mode 100644 index 00000000..85116107 --- /dev/null +++ b/lib/core/theme/parser/token_colors_highlighter_config.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import '../querya_editor_theme.dart'; +import 'vscode_theme_manifest.dart'; + +/// Builds `syntax_highlight` theme JSON from VS Code `tokenColors`. +String buildHighlighterConfigFromTokenColors( + List tokenColors, + Color fallbackForeground, +) { + final settings = >[]; + + for (final rule in tokenColors) { + final style = {}; + if (rule.foreground != null) style['foreground'] = rule.foreground; + if (rule.background != null) style['background'] = rule.background; + if (rule.fontStyle != null) style['fontStyle'] = rule.fontStyle; + if (style.isEmpty) continue; + + settings.add({ + 'scope': rule.scopes.length == 1 ? rule.scopes.single : rule.scopes, + 'settings': style, + }); + } + + settings.add({ + 'settings': {'foreground': _hex(fallbackForeground)}, + }); + + return jsonEncode({'settings': settings}); +} + +String buildDefaultEditorHighlighterConfig(QueryaEditorTheme editor) { + return jsonEncode({ + 'settings': [ + { + 'scope': [ + 'comment', + 'comment.line', + 'comment.block', + ], + 'settings': {'foreground': _hex(editor.comment)}, + }, + { + 'scope': [ + 'keyword', + 'keyword.control', + 'keyword.operator', + 'storage.type', + ], + 'settings': {'foreground': _hex(editor.keyword)}, + }, + { + 'scope': [ + 'string', + 'string.quoted', + 'string.quoted.single', + 'string.quoted.double', + ], + 'settings': {'foreground': _hex(editor.string)}, + }, + { + 'scope': [ + 'constant.numeric', + 'constant.numeric.json', + 'number', + ], + 'settings': {'foreground': _hex(editor.number)}, + }, + { + 'scope': [ + 'support.type.property-name', + 'support.type.property-name.json', + ], + 'settings': {'foreground': _hex(editor.type)}, + }, + { + 'scope': [ + 'constant.language', + 'constant.language.json', + ], + 'settings': {'foreground': _hex(editor.keyword)}, + }, + { + 'scope': ['entity.name.function', 'support.function'], + 'settings': {'foreground': _hex(editor.function)}, + }, + { + 'scope': ['entity.name.type', 'support.type'], + 'settings': {'foreground': _hex(editor.type)}, + }, + { + 'scope': ['constant.language', 'variable.language'], + 'settings': {'foreground': _hex(editor.keyword)}, + }, + { + 'settings': {'foreground': _hex(editor.foreground)}, + }, + ], + }); +} + +String _hex(Color c) { + final a = (c.a * 255).round().clamp(0, 255); + final r = (c.r * 255).round().clamp(0, 255); + final g = (c.g * 255).round().clamp(0, 255); + final b = (c.b * 255).round().clamp(0, 255); + if (a < 255) { + return '#${r.toRadixString(16).padLeft(2, '0')}' + '${g.toRadixString(16).padLeft(2, '0')}' + '${b.toRadixString(16).padLeft(2, '0')}' + '${a.toRadixString(16).padLeft(2, '0')}'; + } + return '#${r.toRadixString(16).padLeft(2, '0')}' + '${g.toRadixString(16).padLeft(2, '0')}' + '${b.toRadixString(16).padLeft(2, '0')}'; +} diff --git a/lib/core/theme/parser/token_style_resolver.dart b/lib/core/theme/parser/token_style_resolver.dart new file mode 100644 index 00000000..66e665f7 --- /dev/null +++ b/lib/core/theme/parser/token_style_resolver.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import 'color_parser.dart'; +import 'vscode_theme_manifest.dart'; + +/// Resolves TextMate scopes to [TextStyle] using VS Code `tokenColors` rules. +/// +/// Walks scope prefixes from most specific to least (`a.b.c` → `a.b` → `a`). +class TokenStyleResolver { + TokenStyleResolver({ + required List rules, + required TextStyle defaultStyle, + }) : _rules = rules, + _defaultStyle = defaultStyle; + + final List _rules; + final TextStyle _defaultStyle; + final Map _cache = {}; + + /// Longest-prefix match for [scope] with per-scope cache. + TextStyle resolve(String scope) => + _cache.putIfAbsent(scope, () => _resolveUncached(scope)); + + TextStyle _resolveUncached(String scope) { + for (final prefix in _scopePrefixes(scope)) { + for (final rule in _rules) { + if (rule.scopes.contains(prefix)) { + return _styleFromRule(rule); + } + } + } + return _defaultStyle; + } + + List _scopePrefixes(String scope) { + final parts = scope.split('.'); + return [ + for (var i = parts.length; i >= 1; i--) + parts.sublist(0, i).join('.'), + ]; + } + + TextStyle _styleFromRule(TokenColorRule rule) { + Color? color; + if (rule.foreground != null) { + try { + color = parseVsCodeColor(rule.foreground!); + } on FormatException { + color = null; + } + } + + FontStyle? fontStyle; + FontWeight? fontWeight; + TextDecoration? decoration; + final fs = rule.fontStyle?.toLowerCase(); + if (fs != null) { + if (fs.contains('italic')) fontStyle = FontStyle.italic; + if (fs.contains('bold')) fontWeight = FontWeight.bold; + if (fs.contains('underline')) decoration = TextDecoration.underline; + } + + return _defaultStyle.copyWith( + color: color ?? _defaultStyle.color, + fontStyle: fontStyle ?? _defaultStyle.fontStyle, + fontWeight: fontWeight ?? _defaultStyle.fontWeight, + decoration: decoration ?? _defaultStyle.decoration, + ); + } +} diff --git a/lib/core/theme/parser/vscode_theme_manifest.dart b/lib/core/theme/parser/vscode_theme_manifest.dart index b6e43008..b9f5f6df 100644 --- a/lib/core/theme/parser/vscode_theme_manifest.dart +++ b/lib/core/theme/parser/vscode_theme_manifest.dart @@ -84,6 +84,32 @@ class TokenColorRule { final String? background; final String? fontStyle; + @override + bool operator ==(Object other) => + identical(this, other) || + other is TokenColorRule && + scopes.length == other.scopes.length && + _listEquals(scopes, other.scopes) && + foreground == other.foreground && + background == other.background && + fontStyle == other.fontStyle; + + @override + int get hashCode => Object.hash( + Object.hashAll(scopes), + foreground, + background, + fontStyle, + ); + + static bool _listEquals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + static TokenColorRule? tryParse(Map json) { final scopeRaw = json['scope']; final scopes = []; diff --git a/lib/core/theme/querya_theme.dart b/lib/core/theme/querya_theme.dart index 7982b02b..a941a96d 100644 --- a/lib/core/theme/querya_theme.dart +++ b/lib/core/theme/querya_theme.dart @@ -1,5 +1,6 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'parser/vscode_theme_manifest.dart'; import 'querya_colors.dart'; import 'querya_editor_theme.dart'; import 'querya_workbench_theme.dart'; @@ -11,6 +12,7 @@ class QueryaTheme { required this.editor, required this.brightness, required this.colorScheme, + this.tokenColors = const [], }); final QueryaWorkbenchTheme workbench; @@ -18,6 +20,9 @@ class QueryaTheme { final Brightness brightness; final ColorScheme colorScheme; + /// VS Code `tokenColors` for syntax highlighting (imported themes). + final List tokenColors; + static const QueryaTheme darkDefault = QueryaTheme( workbench: QueryaWorkbenchTheme.darkDefault, editor: QueryaEditorTheme.darkDefault, @@ -131,12 +136,14 @@ class QueryaTheme { QueryaEditorTheme? editor, Brightness? brightness, ColorScheme? colorScheme, + List? tokenColors, }) { return QueryaTheme( workbench: workbench ?? this.workbench, editor: editor ?? this.editor, brightness: brightness ?? this.brightness, colorScheme: colorScheme ?? this.colorScheme, + tokenColors: tokenColors ?? this.tokenColors, ); } @@ -149,6 +156,7 @@ class QueryaTheme { editor: e, brightness: brightness, colorScheme: ColorScheme.lerp(a.colorScheme, b.colorScheme, t), + tokenColors: t < 0.5 ? a.tokenColors : b.tokenColors, ); } @@ -175,9 +183,18 @@ class QueryaTheme { workbench == other.workbench && editor == other.editor && brightness == other.brightness && - colorScheme == other.colorScheme; + colorScheme == other.colorScheme && + _listEquals(tokenColors, other.tokenColors); @override int get hashCode => - Object.hash(workbench, editor, brightness, colorScheme); + Object.hash(workbench, editor, brightness, colorScheme, tokenColors); + + static bool _listEquals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } } diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 4b38e8d2..70eeec09 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -1,9 +1,11 @@ import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'parser/apply_token_colors_to_editor.dart'; import 'parser/color_parser.dart'; import 'parser/querya_theme_from_vscode.dart'; import 'parser/vscode_colors_merge.dart'; +import 'parser/vscode_theme_manifest.dart'; import 'querya_theme.dart'; import 'querya_theme_preset.dart'; import 'theme_import_service.dart'; @@ -17,6 +19,7 @@ class ThemeController extends ChangeNotifier { ThemeMode _themeMode = ThemeMode.dark; QueryaThemePreset _preset = QueryaThemePreset.queryaDark; Map _importedColors = const {}; + List _importedTokenColors = const []; Map _userOverrides = const {}; String? _importedThemeName; bool _loaded = false; @@ -85,6 +88,7 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.setThemeImportedColors(imported); } } + _importedTokenColors = await ThemeImportService.loadPersistedTokenColors(); if (preset == QueryaThemePreset.imported && imported.isEmpty) { preset = QueryaThemePreset.queryaDark; @@ -132,8 +136,15 @@ class ThemeController extends ChangeNotifier { Future importThemeFromFile(String path) async { final result = await ThemeImportService.importFromPath(path); switch (result) { - case ThemeImportSuccess(:final name, :final isDark, :final colors, :final storedPath): + case ThemeImportSuccess( + :final name, + :final isDark, + :final colors, + :final tokenColors, + :final storedPath, + ): _importedColors = Map.unmodifiable(colors); + _importedTokenColors = List.unmodifiable(tokenColors); _importedThemeName = name; _preset = QueryaThemePreset.imported; _themeMode = isDark ? ThemeMode.dark : ThemeMode.light; @@ -174,6 +185,7 @@ class ThemeController extends ChangeNotifier { await ThemeImportService.deletePersistedImport(); await AppSettings.instance.clearThemeImport(); _importedColors = const {}; + _importedTokenColors = const []; _importedThemeName = null; if (_preset == QueryaThemePreset.imported) { _preset = QueryaThemePreset.queryaDark; @@ -190,6 +202,7 @@ class ThemeController extends ChangeNotifier { _themeMode = ThemeMode.dark; _preset = QueryaThemePreset.queryaDark; _importedColors = const {}; + _importedTokenColors = const []; _userOverrides = const {}; _importedThemeName = null; notifyListeners(); @@ -208,11 +221,22 @@ class ThemeController extends ChangeNotifier { ? QueryaTheme.lightDefault : QueryaTheme.darkDefault; final merged = effectiveVsCodeColors; - if (merged.isEmpty) return fallback; - return buildQueryaThemeFromVsCodeColors( - brightness: brightness, - colors: merged, - fallback: fallback, - ); + if (merged.isEmpty && _importedTokenColors.isEmpty) return fallback; + + var theme = merged.isEmpty + ? fallback + : buildQueryaThemeFromVsCodeColors( + brightness: brightness, + colors: merged, + fallback: fallback, + ); + + if (_importedTokenColors.isNotEmpty) { + theme = theme.copyWith( + tokenColors: _importedTokenColors, + editor: applyTokenColorsToEditor(theme.editor, _importedTokenColors), + ); + } + return theme; } } diff --git a/lib/core/theme/theme_import_service.dart b/lib/core/theme/theme_import_service.dart index 3d1d3459..7a40e5d0 100644 --- a/lib/core/theme/theme_import_service.dart +++ b/lib/core/theme/theme_import_service.dart @@ -15,12 +15,14 @@ class ThemeImportSuccess extends ThemeImportResult { required this.name, required this.isDark, required this.colors, + required this.tokenColors, required this.storedPath, }); final String name; final bool isDark; final Map colors; + final List tokenColors; final String storedPath; } @@ -60,6 +62,7 @@ abstract final class ThemeImportService { name: name, isDark: manifest.isDark || !manifest.isLight, colors: Map.unmodifiable(manifest.colors), + tokenColors: List.unmodifiable(manifest.tokenColors), storedPath: storedFile.path, ); } on VsCodeThemeParseException catch (e) { @@ -75,13 +78,23 @@ abstract final class ThemeImportService { /// Reloads colors from the persisted import file, if present. static Future?> loadPersistedColors() async { + final manifest = await loadPersistedManifest(); + if (manifest == null || manifest.colors.isEmpty) return null; + return manifest.colors; + } + + /// Reloads `tokenColors` from the persisted import file, if present. + static Future> loadPersistedTokenColors() async { + final manifest = await loadPersistedManifest(); + return manifest?.tokenColors ?? const []; + } + + /// Full parsed manifest from the persisted import copy. + static Future loadPersistedManifest() async { final file = await _storedThemeFile(); if (!await file.exists()) return null; try { - final manifest = - VsCodeThemeManifest.fromJsonString(await file.readAsString()); - if (manifest.colors.isEmpty) return null; - return manifest.colors; + return VsCodeThemeManifest.fromJsonString(await file.readAsString()); } on Object { return null; } diff --git a/test/core/editor/syntax_highlight_isolate_test.dart b/test/core/editor/syntax_highlight_isolate_test.dart new file mode 100644 index 00000000..97be6345 --- /dev/null +++ b/test/core/editor/syntax_highlight_isolate_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/editor/syntax_highlight_isolate.dart'; +import 'package:querya_desktop/core/editor/querya_code_language.dart'; +import 'package:querya_desktop/core/editor/syntax_highlight_service.dart'; +import 'package:querya_desktop/core/theme/parser/token_colors_highlighter_config.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; + +void main() { + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + await SyntaxHighlightService.ensureInitialized(); + }); + + test('large SQL buffer highlights off main thread', () async { + final code = List.filled(500, 'SELECT id FROM users; -- row').join('\n'); + expect(code.length, greaterThan(kSyntaxHighlightIsolateThreshold)); + + final config = buildDefaultEditorHighlighterConfig( + QueryaTheme.darkDefault.editor, + ); + final segments = await highlightOffMainThread( + SyntaxHighlightJob( + code: code, + language: 'sql', + themeConfigJson: config, + grammarJson: SyntaxHighlightService.grammarJsonFor( + QueryaCodeLanguage.sql, + ), + wrapperArgb: QueryaTheme.darkDefault.editor.foreground.toARGB32(), + ), + ); + + expect(segments, isNotEmpty); + expect(segments.map((s) => s.text).join(), code); + }); +} diff --git a/test/core/theme/parser/token_colors_dracula_sql_test.dart b/test/core/theme/parser/token_colors_dracula_sql_test.dart new file mode 100644 index 00000000..f2d8a340 --- /dev/null +++ b/test/core/theme/parser/token_colors_dracula_sql_test.dart @@ -0,0 +1,46 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/editor/highlighter_theme_from_querya.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:syntax_highlight/syntax_highlight.dart'; + +void main() { + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + await Highlighter.initialize(['sql']); + }); + + test('Dracula-like tokenColors distinguish comment, keyword, string in SQL', () { + final raw = File('test/fixtures/themes/dracula_tokens.json').readAsStringSync(); + final manifest = VsCodeThemeManifest.fromJsonString(raw); + final theme = highlighterThemeFromQueryaEditor( + QueryaTheme.darkDefault.editor, + tokenColors: manifest.tokenColors, + ); + final highlighter = Highlighter(language: 'sql', theme: theme); + const sql = 'SELECT 1 -- note\n\'hello\''; + final span = highlighter.highlight(sql); + final colors = _collectColors(span); + expect(colors.length, greaterThanOrEqualTo(3)); + expect(colors.toSet().length, greaterThanOrEqualTo(3)); + }); +} + +Set _collectColors(TextSpan span) { + final colors = {}; + void walk(TextSpan node) { + final c = node.style?.color; + if (c != null) colors.add(c); + if (node.children != null) { + for (final child in node.children!) { + if (child is TextSpan) walk(child); + } + } + } + + walk(span); + return colors; +} diff --git a/test/core/theme/parser/token_style_resolver_test.dart b/test/core/theme/parser/token_style_resolver_test.dart new file mode 100644 index 00000000..c2badd7e --- /dev/null +++ b/test/core/theme/parser/token_style_resolver_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/token_style_resolver.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; + +void main() { + test('resolves longest matching scope prefix', () { + const rules = [ + TokenColorRule( + scopes: ['comment'], + foreground: '#111111', + ), + TokenColorRule( + scopes: ['keyword'], + foreground: '#222222', + ), + ]; + final resolver = TokenStyleResolver( + rules: rules, + defaultStyle: const TextStyle(color: Color(0xFFFFFFFF)), + ); + + expect(resolver.resolve('comment.line.sql').color, const Color(0xFF111111)); + expect(resolver.resolve('keyword.control').color, const Color(0xFF222222)); + expect(resolver.resolve('unknown.scope').color, const Color(0xFFFFFFFF)); + }); + + test('caches repeated scope lookups', () { + const rules = [ + TokenColorRule(scopes: ['string'], foreground: '#ABCDEF'), + ]; + final resolver = TokenStyleResolver( + rules: rules, + defaultStyle: const TextStyle(color: Color(0xFF000000)), + ); + final a = resolver.resolve('string.quoted.double'); + final b = resolver.resolve('string.quoted.double'); + expect(identical(a, b), isTrue); + }); +} diff --git a/test/fixtures/themes/dracula_tokens.json b/test/fixtures/themes/dracula_tokens.json new file mode 100644 index 00000000..72291d7b --- /dev/null +++ b/test/fixtures/themes/dracula_tokens.json @@ -0,0 +1,26 @@ +{ + "name": "Dracula Fixture", + "type": "dark", + "colors": { + "editor.background": "#282a36", + "editor.foreground": "#f8f8f2" + }, + "tokenColors": [ + { + "scope": ["comment", "comment.line", "comment.block"], + "settings": { "foreground": "#6272a4" } + }, + { + "scope": ["keyword", "keyword.control", "storage.type"], + "settings": { "foreground": "#ff79c6" } + }, + { + "scope": ["string", "string.quoted.double"], + "settings": { "foreground": "#f1fa8c" } + }, + { + "scope": ["constant.numeric"], + "settings": { "foreground": "#bd93f9" } + } + ] +}