From 80df6cbc88ab3ce5df61e89a15ea803026eef752 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Wed, 8 Apr 2026 22:50:19 +0800 Subject: [PATCH] feat: enhance HighlightTextWidget with language support and refactor highlighting logic (#721) --- lib/ui/component/search/highlight_text.dart | 95 ++--- .../search/highlight_text_document.dart | 354 ++++++++++++++++++ .../component/search/search_controller.dart | 9 +- lib/ui/component/search_condition.dart | 2 +- lib/ui/content/body.dart | 68 +++- lib/ui/desktop/request/search.dart | 2 +- test/highlight_text_test.dart | 145 +++++++ 7 files changed, 590 insertions(+), 85 deletions(-) create mode 100644 lib/ui/component/search/highlight_text_document.dart create mode 100644 test/highlight_text_test.dart diff --git a/lib/ui/component/search/highlight_text.dart b/lib/ui/component/search/highlight_text.dart index 254b037..59748d3 100644 --- a/lib/ui/component/search/highlight_text.dart +++ b/lib/ui/component/search/highlight_text.dart @@ -1,95 +1,48 @@ import 'package:flutter/material.dart'; +import 'package:proxypin/ui/component/search/highlight_text_document.dart'; import 'package:proxypin/ui/component/search/search_controller.dart'; class HighlightTextWidget extends StatelessWidget { final String text; final TextStyle? style; + final String? language; final EditableTextContextMenuBuilder? contextMenuBuilder; final SearchTextController searchController; - const HighlightTextWidget( - {super.key, required this.text, this.contextMenuBuilder, required this.searchController, this.style}); + const HighlightTextWidget({ + super.key, + required this.text, + this.style, + this.language, + this.contextMenuBuilder, + required this.searchController, + }); @override Widget build(BuildContext context) { return AnimatedBuilder( animation: searchController, builder: (context, child) { - final spans = _highlightMatches(context); + final document = HighlightTextDocument.create( + context, + text: text, + style: style, + language: language, + searchController: searchController, + ); + final spans = document.buildAllSpans(context); + + WidgetsBinding.instance.addPostFrameCallback((_) { + searchController.updateMatchCount(document.totalMatchCount); + }); + return SelectableText.rich( TextSpan(children: spans), showCursor: true, + selectionColor: highlightSelectionColor(context), contextMenuBuilder: contextMenuBuilder, ); }, ); } - - List _highlightMatches(BuildContext context) { - if (!searchController.shouldSearch()) { - return [TextSpan(text: text, style: style)]; - } - - final pattern = searchController.value.pattern; - final regex = searchController.value.isRegExp - ? RegExp(pattern, caseSensitive: searchController.value.isCaseSensitive) - : RegExp( - RegExp.escape(pattern), - caseSensitive: searchController.value.isCaseSensitive, - ); - - final spans = []; - int start = 0; - var allMatches = regex.allMatches(text).toList(); - final currentIndex = searchController.currentMatchIndex.value; - ColorScheme colorScheme = ColorScheme.of(context); - List matchKeys = []; - for (int i = 0; i < allMatches.length; i++) { - final match = allMatches[i]; - if (match.start > start) { - spans.add(TextSpan(text: text.substring(start, match.start), style: style)); - } - - // 为每个高亮项分配一个 GlobalKey - final key = GlobalKey(); - matchKeys.add(key); - spans.add(WidgetSpan( - alignment: PlaceholderAlignment.middle, - baseline: TextBaseline.ideographic, - child: Container( - key: key, - color: i == currentIndex ? colorScheme.primary : colorScheme.inversePrimary, - child: Text(text.substring(match.start, match.end), style: style), - ))); - start = match.end; - } - if (start < text.length) { - spans.add(TextSpan(text: text.substring(start), style: style)); - } - - WidgetsBinding.instance.addPostFrameCallback((_) { - searchController.updateMatchCount(allMatches.length); - _scrollToMatch(context, matchKeys); - matchKeys.clear(); - }); - - return spans; - } - - void _scrollToMatch(BuildContext context, List matchKeys) { - if (matchKeys.isNotEmpty) { - final currentIndex = searchController.currentMatchIndex.value; - if (currentIndex >= 0 && currentIndex < matchKeys.length) { - final key = matchKeys[currentIndex]; - final context = key.currentContext; - if (context != null) { - Scrollable.ensureVisible( - context, - duration: const Duration(milliseconds: 300), - alignment: 0.5, // 高亮项在视图中的位置 - ); - } - } - } - } } diff --git a/lib/ui/component/search/highlight_text_document.dart b/lib/ui/component/search/highlight_text_document.dart new file mode 100644 index 0000000..80f91aa --- /dev/null +++ b/lib/ui/component/search/highlight_text_document.dart @@ -0,0 +1,354 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_highlight/themes/atom-one-dark.dart'; +import 'package:flutter_highlight/themes/atom-one-light.dart'; +import 'package:highlight/highlight.dart' show Node, highlight; + +import 'search_controller.dart'; + +class HighlightTextDocument { + final String text; + final TextStyle rootStyle; + final List segments; + final List matches; + final List lines; + final List> lineMatches; + final List matchLineIndexes; + final int currentMatchIndex; + + const HighlightTextDocument._({ + required this.text, + required this.rootStyle, + required this.segments, + required this.matches, + required this.lines, + required this.lineMatches, + required this.matchLineIndexes, + required this.currentMatchIndex, + }); + + factory HighlightTextDocument.create( + BuildContext context, { + required String text, + String? language, + TextStyle? style, + required SearchTextController searchController, + }) { + final rootStyle = highlightRootStyle(context, style); + final segments = buildHighlightBaseSegments(context, text, language: language, style: style); + final matches = buildSearchMatches(text, searchController); + final lines = buildHighlightDocumentLines(segments); + final groupedMatches = _groupMatchesByLine(lines, matches); + final currentMatchIndex = matches.isEmpty ? -1 : searchController.currentMatchIndex.value.clamp(0, matches.length - 1); + final matchLineIndexes = _buildMatchLineIndexes(groupedMatches, matches.length); + + return HighlightTextDocument._( + text: text, + rootStyle: rootStyle, + segments: segments, + matches: matches, + lines: lines, + lineMatches: groupedMatches, + matchLineIndexes: matchLineIndexes, + currentMatchIndex: currentMatchIndex, + ); + } + + int get totalMatchCount => matches.length; + + int? lineIndexForMatch(int matchIndex) { + if (matchIndex < 0 || matchIndex >= matchLineIndexes.length) { + return null; + } + return matchLineIndexes[matchIndex]; + } + + List buildAllSpans(BuildContext context) { + final spans = []; + for (var i = 0; i < lines.length; i++) { + spans.addAll(buildSpansForLine(context, i)); + if (i != lines.length - 1) { + spans.add(TextSpan(text: '\n', style: rootStyle)); + } + } + return spans; + } + + List buildSpansForLine(BuildContext context, int lineIndex) { + final line = lines[lineIndex]; + final matchesForLine = lineMatches[lineIndex]; + if (matchesForLine.isEmpty) { + return _plainLineSpans(line); + } + + final spans = []; + final colorScheme = ColorScheme.of(context); + var matchIndex = 0; + var consumed = 0; + + for (final segment in line.segments) { + final segmentStart = line.start + consumed; + final segmentEnd = segmentStart + segment.text.length; + var localStart = 0; + + while (localStart < segment.text.length) { + while (matchIndex < matchesForLine.length && matchesForLine[matchIndex].end <= segmentStart + localStart) { + matchIndex++; + } + + if (matchIndex >= matchesForLine.length || matchesForLine[matchIndex].start >= segmentEnd) { + _appendTextSpan(spans, segment.text.substring(localStart), segment.style); + break; + } + + final match = matchesForLine[matchIndex]; + final absoluteStart = segmentStart + localStart; + + if (match.start > absoluteStart) { + final plainEnd = match.start - segmentStart; + _appendTextSpan(spans, segment.text.substring(localStart, plainEnd), segment.style); + localStart = plainEnd; + continue; + } + + final overlapEnd = match.end < segmentEnd ? match.end : segmentEnd; + final matchText = segment.text.substring(localStart, overlapEnd - segmentStart); + final isCurrentMatch = match.index == currentMatchIndex; + final highlightedStyle = (segment.style ?? const TextStyle()).copyWith( + backgroundColor: isCurrentMatch ? colorScheme.primary : colorScheme.inversePrimary, + color: isCurrentMatch ? colorScheme.onPrimary : segment.style?.color, + ); + + _appendTextSpan(spans, matchText, highlightedStyle); + + localStart = overlapEnd - segmentStart; + } + + consumed += segment.text.length; + } + + return spans; + } + + List _plainLineSpans(HighlightDocumentLine line) { + if (line.segments.isEmpty) { + return [const TextSpan(text: '\u200B', style: TextStyle(color: Colors.transparent))]; + } + + return [for (final segment in line.segments) TextSpan(text: segment.text, style: segment.style)]; + } +} + +TextStyle highlightRootStyle(BuildContext context, [TextStyle? style]) { + final theme = Theme.brightnessOf(context) == Brightness.light ? atomOneLightTheme : atomOneDarkTheme; + return _stripBackground((theme['root'] ?? const TextStyle(fontFamily: 'monospace', fontSize: 14.5)).merge(style)) ?? + const TextStyle(fontFamily: 'monospace', fontSize: 14.5); +} + +List buildHighlightBaseSegments( + BuildContext context, + String text, { + String? language, + TextStyle? style, +}) { + if (!(language?.isNotEmpty ?? false)) { + return [HighlightStyledSegment(text: text, style: _stripBackground(style))]; + } + + try { + final parsed = highlight.parse(text, language: language).nodes ?? const []; + final theme = Theme.brightnessOf(context) == Brightness.light ? atomOneLightTheme : atomOneDarkTheme; + + List convert(List nodes, [TextStyle? inheritedStyle]) { + final spans = []; + for (final node in nodes) { + final nodeStyle = node.className == null ? null : _stripBackground(theme[node.className!]); + final mergedStyle = _stripBackground(inheritedStyle?.merge(nodeStyle) ?? nodeStyle); + + if (node.value != null) { + spans.add(HighlightStyledSegment(text: node.value!, style: mergedStyle)); + continue; + } + + if (node.children != null && node.children!.isNotEmpty) { + spans.addAll(convert(node.children!, mergedStyle)); + } + } + return spans; + } + + final result = convert(parsed); + if (result.isNotEmpty) { + return result; + } + } catch (_) {} + + return [HighlightStyledSegment(text: text, style: _stripBackground(style))]; +} + +Color highlightSelectionColor(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final configured = Theme.of(context).textSelectionTheme.selectionColor; + // Enforce minimum contrast for syntax-colored text selection. + if (configured != null && configured.alpha >= 140) { + return configured; + } + return scheme.primary.withValues(alpha: 0.55); +} + +List buildSearchMatches(String text, SearchTextController searchController) { + if (!searchController.shouldSearch()) { + return const []; + } + + final pattern = searchController.value.pattern; + if (pattern.isEmpty) { + return const []; + } + + try { + final regex = searchController.value.isRegExp + ? RegExp(pattern, caseSensitive: searchController.value.isCaseSensitive) + : RegExp(RegExp.escape(pattern), caseSensitive: searchController.value.isCaseSensitive); + + final matches = []; + var index = 0; + for (final match in regex.allMatches(text)) { + if (match.start == match.end) { + continue; + } + matches.add(HighlightSearchMatch(index: index, start: match.start, end: match.end)); + index++; + } + return matches; + } catch (_) { + return const []; + } +} + +List buildHighlightDocumentLines(List segments) { + final lines = []; + final currentSegments = []; + var lineStart = 0; + var offset = 0; + var lineNumber = 0; + + void pushLine() { + lines.add(HighlightDocumentLine( + index: lineNumber++, + start: lineStart, + end: offset, + segments: List.from(currentSegments), + )); + currentSegments.clear(); + } + + for (final segment in segments) { + final parts = segment.text.split('\n'); + for (var i = 0; i < parts.length; i++) { + final part = parts[i]; + if (part.isNotEmpty) { + currentSegments.add(HighlightStyledSegment(text: part, style: segment.style)); + } + offset += part.length; + + if (i != parts.length - 1) { + pushLine(); + offset += 1; + lineStart = offset; + } + } + } + + if (lines.isEmpty || lineStart <= offset) { + pushLine(); + } + + return lines; +} + +void _appendTextSpan(List spans, String value, TextStyle? textStyle) { + if (value.isEmpty) { + return; + } + spans.add(TextSpan(text: value, style: textStyle)); +} + +List> _groupMatchesByLine( + List lines, + List matches, +) { + final grouped = List.generate(lines.length, (_) => []); + if (matches.isEmpty || lines.isEmpty) { + return grouped; + } + + var lineIndex = 0; + for (final match in matches) { + while (lineIndex < lines.length && lines[lineIndex].end <= match.start) { + lineIndex++; + } + + for (var i = lineIndex; i < lines.length; i++) { + final line = lines[i]; + if (line.start >= match.end) { + break; + } + if (line.end > match.start) { + grouped[i].add(match); + } + } + } + + return grouped; +} + +List _buildMatchLineIndexes(List> groupedMatches, int matchCount) { + final indexes = List.filled(matchCount, 0); + for (var lineIndex = 0; lineIndex < groupedMatches.length; lineIndex++) { + for (final match in groupedMatches[lineIndex]) { + if (match.index < indexes.length && indexes[match.index] == 0) { + indexes[match.index] = lineIndex; + } + } + } + return indexes; +} + +class HighlightStyledSegment { + final String text; + final TextStyle? style; + + const HighlightStyledSegment({required this.text, this.style}); +} + +class HighlightSearchMatch { + final int index; + final int start; + final int end; + + const HighlightSearchMatch({required this.index, required this.start, required this.end}); +} + +class HighlightDocumentLine { + final int index; + final int start; + final int end; + final List segments; + + const HighlightDocumentLine({ + required this.index, + required this.start, + required this.end, + required this.segments, + }); + + String get text => segments.map((segment) => segment.text).join(); +} + +TextStyle? _stripBackground(TextStyle? style) { + if (style == null) { + return null; + } + return style.copyWith(backgroundColor: null, background: null); +} + diff --git a/lib/ui/component/search/search_controller.dart b/lib/ui/component/search/search_controller.dart index 2d77533..c05674f 100644 --- a/lib/ui/component/search/search_controller.dart +++ b/lib/ui/component/search/search_controller.dart @@ -41,8 +41,13 @@ class SearchTextController extends ValueNotifier with WidgetsBin void updateMatchCount(int count) { totalMatchCount.value = count; - if (currentMatchIndex.value > count) { - currentMatchIndex.value = count; + if (count == 0) { + currentMatchIndex.value = 0; + return; + } + + if (currentMatchIndex.value >= count) { + currentMatchIndex.value = count - 1; } } diff --git a/lib/ui/component/search_condition.dart b/lib/ui/component/search_condition.dart index e295aef..8e5474e 100644 --- a/lib/ui/component/search_condition.dart +++ b/lib/ui/component/search_condition.dart @@ -49,10 +49,10 @@ class SearchConditionsState extends State { 'JSON': ContentType.json, 'IMAGE': ContentType.image, 'HTML': ContentType.html, + 'XML': ContentType.xml, 'JS': ContentType.js, 'CSS': ContentType.css, 'TEXT': ContentType.text, - 'XML': ContentType.xml, }; late SearchModel searchModel; diff --git a/lib/ui/content/body.dart b/lib/ui/content/body.dart index 9dd1b53..0e24448 100644 --- a/lib/ui/content/body.dart +++ b/lib/ui/content/body.dart @@ -21,9 +21,9 @@ import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:proxypin/l10n/app_localizations.dart'; import 'package:flutter_toastr/flutter_toastr.dart'; import 'package:image_pickers/image_pickers.dart'; +import 'package:proxypin/l10n/app_localizations.dart'; import 'package:proxypin/network/components/manager/request_rewrite_manager.dart'; import 'package:proxypin/network/components/manager/rewrite_rule.dart'; import 'package:proxypin/network/http/content_type.dart'; @@ -560,6 +560,11 @@ class _BodyState extends State<_Body> { final body = parent?.showDecoded == true && parent?.decoded?.text != null ? parent!.decoded!.text! : await currentMessage.decodeBodyString(); + + if (viewType == ViewType.text) { + return body; + } + return _formatTextBody(viewType, body); } @@ -617,11 +622,7 @@ class _BodyState extends State<_Body> { contextMenuBuilder: contextMenu); } - final initialData = (type == ViewType.html || type == ViewType.xml) - ? _formatTextBody(type, message.getBodyString()) - : message.getBodyString(); - - return futureWidget(message.decodeBodyString(), initialData: initialData, (body) { + return futureWidget(message.decodeBodyString(), initialData: message.getBodyString(), (body) { try { if (type == ViewType.jsonText) { var jsonObject = json.decode(body); @@ -638,10 +639,7 @@ class _BodyState extends State<_Body> { colorTheme: ColorTheme.of(context), searchController: widget.searchController); } - return HighlightTextWidget( - text: _formatTextBody(type, body), - searchController: widget.searchController, - contextMenuBuilder: contextMenu); + return _buildTextBodyViewer(type, body, message: message); } catch (e) { logger.e(e, stackTrace: StackTrace.current); } @@ -650,6 +648,56 @@ class _BodyState extends State<_Body> { text: body, searchController: widget.searchController, contextMenuBuilder: contextMenu); }); } + + String? _languageForViewType(ViewType type, HttpMessage? message) { + switch (type) { + case ViewType.html: + return 'html'; + case ViewType.xml: + return 'xml'; + case ViewType.css: + return 'css'; + case ViewType.js: + return 'javascript'; + case ViewType.json: + case ViewType.jsonText: + return 'json'; + default: + return null; + } + } + + Widget _buildTextBodyViewer( + ViewType type, + String text, { + HttpMessage? message, + }) { + final language = _languageForViewType(type, message); + + // bool showVirtualized = text.length > 12000; + // if (showVirtualized) { + // logger.d( + // "Using virtualized viewer for type $type with length ${text.length} and line count ${'\n'.allMatches(text).length + 1}"); + // return VirtualizedHighlightText( + // text: text, + // language: language, + // searchController: widget.searchController, + // contextMenuBuilder: contextMenu, + // scrollController: widget.scrollController, + // ); + // } + + if (type == ViewType.text) { + return HighlightTextWidget( + text: text, searchController: widget.searchController, contextMenuBuilder: contextMenu); + } + + return HighlightTextWidget( + language: language, + text: _formatTextBody(type, text), + searchController: widget.searchController, + contextMenuBuilder: contextMenu); + } } class Tabs { diff --git a/lib/ui/desktop/request/search.dart b/lib/ui/desktop/request/search.dart index 68b806b..06a69ce 100644 --- a/lib/ui/desktop/request/search.dart +++ b/lib/ui/desktop/request/search.dart @@ -155,7 +155,7 @@ class ContentTypeState extends State { @override Widget build(BuildContext context) { value ??= localizations.all; - types ??= ["JSON", "IMAGE", "HTML", "JS", "CSS", "TEXT", "XML", localizations.all]; + types ??= ["JSON", "IMAGE", "HTML", "XML", "JS", "CSS", "TEXT", localizations.all]; return PopupMenuButton( initialValue: value, diff --git a/test/highlight_text_test.dart b/test/highlight_text_test.dart new file mode 100644 index 0000000..9e86cfc --- /dev/null +++ b/test/highlight_text_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:proxypin/ui/component/search/highlight_text.dart'; +import 'package:proxypin/ui/component/search/search_controller.dart'; + +void main() { + group('HighlightTextWidget', () { + testWidgets('keeps syntax highlighting while search is active', (tester) async { + final controller = SearchTextController(); + BuildContext? hostContext; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder(builder: (context) { + hostContext = context; + return HighlightTextWidget( + text: 'const token = 1;\nconst next = token;', + language: 'javascript', + searchController: controller, + ); + }), + ), + )); + + controller.patternController.text = 'token'; + controller.showSearchOverlay(hostContext!, top: 0, right: 0); + await tester.pump(); + await tester.pump(); + + expect(controller.totalMatchCount.value, 2); + + final selectable = tester.widget(find.byType(SelectableText)); + final rootSpan = selectable.textSpan!; + final keywordSpan = _flattenTextSpans(rootSpan).firstWhere((span) => span.text?.contains('const') ?? false); + + expect(keywordSpan.style?.color, isNotNull); + expect((rootSpan.children ?? const []).whereType(), isEmpty); + + await _disposeController(tester, controller); + }); + + testWidgets('invalid regular expressions safely produce zero matches', (tester) async { + final controller = SearchTextController(); + BuildContext? hostContext; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder(builder: (context) { + hostContext = context; + return HighlightTextWidget( + text: '{"name": "proxypin"}', + language: 'json', + searchController: controller, + ); + }), + ), + )); + + controller.toggleIsRegExp(); + controller.patternController.text = '('; + controller.showSearchOverlay(hostContext!, top: 0, right: 0); + await tester.pump(); + await tester.pump(); + + expect(controller.totalMatchCount.value, 0); + expect(find.byType(SelectableText), findsOneWidget); + + await _disposeController(tester, controller); + }); + + testWidgets('current match index is clamped when match count shrinks', (tester) async { + final controller = SearchTextController(); + BuildContext? hostContext; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder(builder: (context) { + hostContext = context; + return HighlightTextWidget( + text: 'foo bar foo', + searchController: controller, + ); + }), + ), + )); + + controller.patternController.text = 'foo'; + controller.showSearchOverlay(hostContext!, top: 0, right: 0); + await tester.pump(); + await tester.pump(); + + controller.moveNext(); + await tester.pump(); + expect(controller.currentMatchIndex.value, 1); + + controller.patternController.text = 'bar'; + await tester.pump(); + await tester.pump(); + + expect(controller.totalMatchCount.value, 1); + expect(controller.currentMatchIndex.value, 0); + + await _disposeController(tester, controller); + }); + + testWidgets('forwards the custom context menu builder', (tester) async { + final controller = SearchTextController(); + Widget menuBuilder(BuildContext context, EditableTextState editableTextState) => const SizedBox.shrink(); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: HighlightTextWidget( + text: 'plain text', + searchController: controller, + contextMenuBuilder: menuBuilder, + ), + ), + )); + + final selectable = tester.widget(find.byType(SelectableText)); + expect(selectable.contextMenuBuilder, same(menuBuilder)); + + await _disposeController(tester, controller); + }); + }); +} + +Future _disposeController(WidgetTester tester, SearchTextController controller) async { + controller.closeSearch(); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + controller.dispose(); +} + +Iterable _flattenTextSpans(InlineSpan span) sync* { + if (span is! TextSpan) { + return; + } + + yield span; + for (final child in span.children ?? const []) { + yield* _flattenTextSpans(child); + } +} +