From 533e6fb7820d7225ab254ef713d93963346c39f5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 11 Oct 2026 15:54:00 +0300 Subject: [PATCH] perf(editor): eliminate syntax highlight flicker on documents >= 8KB (#1351) --- .../editor/querya_highlight_controller.dart | 21 ++++ lib/core/editor/syntax_highlight_isolate.dart | 112 +++++++++++++++++- .../querya_highlight_controller_test.dart | 83 ++++++++++++- 3 files changed, 211 insertions(+), 5 deletions(-) diff --git a/lib/core/editor/querya_highlight_controller.dart b/lib/core/editor/querya_highlight_controller.dart index 4a3912ae..04cba12e 100644 --- a/lib/core/editor/querya_highlight_controller.dart +++ b/lib/core/editor/querya_highlight_controller.dart @@ -44,12 +44,14 @@ class QueryaHighlightController extends TextEditingController { this.darkThemeConfig = darkThemeConfig; this.wrapperColor = wrapperColor; _cachedSpan = null; + _cachedSegments = null; _cachedText = null; _cachedBrightness = null; notifyListeners(); } TextSpan? _cachedSpan; + List? _cachedSegments; String? _cachedText; Brightness? _cachedBrightness; int _highlightGeneration = 0; @@ -77,6 +79,7 @@ class QueryaHighlightController extends TextEditingController { try { final span = highlighter.highlight(text); _cachedSpan = span; + _cachedSegments = flattenSpan(span); _cachedText = text; _cachedBrightness = brightness; _debounceTimer?.cancel(); @@ -97,6 +100,23 @@ class QueryaHighlightController extends TextEditingController { return TextSpan(style: style, children: [_cachedSpan!]); } + // Preserve syntax colors during active typing in documents >= 8KB instead + // of dropping to unhighlighted monochrome plain text while the background + // isolate highlighting is in flight (#1351). + if (_cachedSegments != null && + _cachedText != null && + _cachedBrightness == brightness) { + final patched = patchHighlightSegments( + oldText: _cachedText!, + oldSegments: _cachedSegments!, + newText: text, + ); + _cachedSegments = patched; + _cachedText = text; + _cachedSpan = segmentsToTextSpan(patched, baseStyle: style); + return TextSpan(style: style, children: [_cachedSpan!]); + } + return TextSpan(style: style, text: text); } @@ -140,6 +160,7 @@ class QueryaHighlightController extends TextEditingController { ), ).then((segments) { if (generation != _highlightGeneration) return; + _cachedSegments = segments; _cachedSpan = segmentsToTextSpan(segments, baseStyle: style); _cachedText = text; _cachedBrightness = brightness; diff --git a/lib/core/editor/syntax_highlight_isolate.dart b/lib/core/editor/syntax_highlight_isolate.dart index 74c4cc0d..7250b005 100644 --- a/lib/core/editor/syntax_highlight_isolate.dart +++ b/lib/core/editor/syntax_highlight_isolate.dart @@ -52,10 +52,11 @@ List syntaxHighlightInIsolate(SyntaxHighlightJob job) { ); final highlighter = Highlighter(language: job.language, theme: theme); final span = highlighter.highlight(job.code); - return _flattenSpan(span); + return flattenSpan(span); } -List _flattenSpan(TextSpan span) { +/// Flattens a nested [TextSpan] into a linear list of [HighlightSegment]s. +List flattenSpan(TextSpan span) { final out = []; void walk(TextSpan node) { final style = node.style; @@ -80,6 +81,113 @@ List _flattenSpan(TextSpan span) { return out; } +/// Patches cached [oldSegments] to match [newText] while preserving formatting +/// and syntax colors for untouched regions. Used during interactive typing in +/// large documents (>= 8KB) while background isolate highlighting is in flight (#1351). +List patchHighlightSegments({ + required String oldText, + required List oldSegments, + required String newText, +}) { + if (oldText == newText) return oldSegments; + if (oldSegments.isEmpty) { + return [HighlightSegment(text: newText)]; + } + if (newText.isEmpty) { + return const []; + } + + var prefixLen = 0; + final minLen = + oldText.length < newText.length ? oldText.length : newText.length; + while (prefixLen < minLen && + oldText.codeUnitAt(prefixLen) == newText.codeUnitAt(prefixLen)) { + prefixLen++; + } + + var suffixLen = 0; + final maxSuffix = minLen - prefixLen; + while (suffixLen < maxSuffix && + oldText.codeUnitAt(oldText.length - 1 - suffixLen) == + newText.codeUnitAt(newText.length - 1 - suffixLen)) { + suffixLen++; + } + + final result = []; + var offset = 0; + + // 1. Preserve prefix segments. + for (var i = 0; i < oldSegments.length; i++) { + final seg = oldSegments[i]; + final segLen = seg.text.length; + final segStart = offset; + final segEnd = offset + segLen; + offset = segEnd; + + if (segStart >= prefixLen) { + break; + } + if (segEnd <= prefixLen) { + result.add(seg); + } else { + final cutLen = prefixLen - segStart; + if (cutLen > 0) { + result.add( + HighlightSegment( + text: seg.text.substring(0, cutLen), + colorArgb: seg.colorArgb, + fontWeightValue: seg.fontWeightValue, + fontStyleIndex: seg.fontStyleIndex, + ), + ); + } + break; + } + } + + // 2. Insert edited middle text. + final middleLen = (newText.length - suffixLen) - prefixLen; + if (middleLen > 0) { + result.add( + HighlightSegment( + text: newText.substring(prefixLen, newText.length - suffixLen), + ), + ); + } + + // 3. Preserve suffix segments. + final suffixStart = oldText.length - suffixLen; + offset = 0; + for (var i = 0; i < oldSegments.length; i++) { + final seg = oldSegments[i]; + final segLen = seg.text.length; + final segStart = offset; + final segEnd = offset + segLen; + offset = segEnd; + + if (segEnd <= suffixStart) { + continue; + } + if (segStart >= suffixStart) { + result.add(seg); + } else { + final cutStart = suffixStart - segStart; + if (cutStart < segLen) { + result.add( + HighlightSegment( + text: seg.text.substring(cutStart), + colorArgb: seg.colorArgb, + fontWeightValue: seg.fontWeightValue, + fontStyleIndex: seg.fontStyleIndex, + ), + ); + } + } + } + + return result; +} + TextSpan segmentsToTextSpan( List segments, { TextStyle? baseStyle, diff --git a/test/core/editor/querya_highlight_controller_test.dart b/test/core/editor/querya_highlight_controller_test.dart index 2fe0bcb6..f3b4de57 100644 --- a/test/core/editor/querya_highlight_controller_test.dart +++ b/test/core/editor/querya_highlight_controller_test.dart @@ -32,7 +32,7 @@ void main() { ); // 1. Initialize controller with small text (< threshold) to populate _cachedSpan synchronously - const smallText = 'SELECT id FROM users;'; + const smallText = 'SELECT 1 FROM users; -- test'; final controller = QueryaHighlightController( text: smallText, language: QueryaCodeLanguage.sql, @@ -57,7 +57,6 @@ void main() { ), ); - // Initial highlight for small text populates _cachedSpan var span = controller.buildTextSpan( context: buildContext, withComposing: false, @@ -66,7 +65,7 @@ void main() { expect(span.children, isNotNull); // 2. Switch to large text (> 8KB). Stale _cachedSpan from smallText must NOT be returned. - final largeText = List.filled(500, 'SELECT id FROM users; -- row').join('\n'); + final largeText = List.filled(500, 'SELECT 1 FROM users; -- test row').join('\n'); expect(largeText.length, greaterThan(kSyntaxHighlightIsolateThreshold)); controller.text = largeText; @@ -88,6 +87,17 @@ void main() { ); expect(span.toPlainText(), modifiedLargeText); expect(span.toPlainText() == controller.text, isTrue); + // Must NOT fall back to plain monochrome text: children should be present and styled + expect(span.children, isNotNull); + expect(span.children, isNotEmpty); + bool checkColors(InlineSpan node) { + if (node is TextSpan) { + if (node.style?.color != null) return true; + if (node.children != null && node.children!.any(checkColors)) return true; + } + return false; + } + expect(checkColors(span), isTrue, reason: 'Syntax highlighting must be preserved during typing'); // 4. User deletes characters final deletedText = modifiedLargeText.substring(0, modifiedLargeText.length - 25); @@ -99,7 +109,74 @@ void main() { ); expect(span.toPlainText(), deletedText); expect(span.toPlainText() == controller.text, isTrue); + expect(span.children, isNotNull); + expect(span.children, isNotEmpty); controller.dispose(); }); + + group('patchHighlightSegments (#1351)', () { + test('preserves prefix and suffix colors with accurate length on insertion', () { + const oldText = 'SELECT * FROM users WHERE active = 1;'; + const newText = 'SELECT * FROM users WHERE active = 1 AND age > 18;'; + final oldSegments = [ + const HighlightSegment(text: 'SELECT', colorArgb: 0xFF569CD6), + const HighlightSegment(text: ' * FROM ', colorArgb: null), + const HighlightSegment(text: 'users', colorArgb: 0xFF4EC9B0), + const HighlightSegment(text: ' WHERE active = 1;', colorArgb: null), + ]; + + final patched = patchHighlightSegments( + oldText: oldText, + oldSegments: oldSegments, + newText: newText, + ); + + final fullText = patched.map((s) => s.text).join(); + expect(fullText, newText); + expect(patched.first.text, 'SELECT'); + expect(patched.first.colorArgb, 0xFF569CD6); + expect(patched.any((s) => s.text.contains('users') && s.colorArgb == 0xFF4EC9B0), isTrue); + }); + + test('preserves intact segments on character deletion in the middle', () { + const oldText = 'SELECT id, name, email FROM accounts;'; + const newText = 'SELECT id, email FROM accounts;'; + final oldSegments = [ + const HighlightSegment(text: 'SELECT', colorArgb: 0xFF569CD6), + const HighlightSegment(text: ' id, ', colorArgb: null), + const HighlightSegment(text: 'name', colorArgb: 0xFF9CDCFE), + const HighlightSegment(text: ', email FROM accounts;', colorArgb: null), + ]; + + final patched = patchHighlightSegments( + oldText: oldText, + oldSegments: oldSegments, + newText: newText, + ); + + final fullText = patched.map((s) => s.text).join(); + expect(fullText, newText); + expect(patched.first.text, 'SELECT'); + expect(patched.first.colorArgb, 0xFF569CD6); + }); + + test('handles empty strings and complete replacement cleanly', () { + expect( + patchHighlightSegments( + oldText: 'abc', + oldSegments: [const HighlightSegment(text: 'abc', colorArgb: 123)], + newText: '', + ), + isEmpty, + ); + + final replaced = patchHighlightSegments( + oldText: 'abc', + oldSegments: [const HighlightSegment(text: 'abc', colorArgb: 123)], + newText: 'xyz', + ); + expect(replaced.map((s) => s.text).join(), 'xyz'); + }); + }); }