feat: enhance HighlightTextWidget with language support and refactor highlighting logic (#721)

This commit is contained in:
wanghongenpin
2026-04-08 22:50:19 +08:00
parent bcd8af3e2d
commit 80df6cbc88
7 changed files with 590 additions and 85 deletions

View File

@@ -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<InlineSpan> _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 = <InlineSpan>[];
int start = 0;
var allMatches = regex.allMatches(text).toList();
final currentIndex = searchController.currentMatchIndex.value;
ColorScheme colorScheme = ColorScheme.of(context);
List<GlobalKey> 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<GlobalKey> 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, // 高亮项在视图中的位置
);
}
}
}
}
}

View File

@@ -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<HighlightStyledSegment> segments;
final List<HighlightSearchMatch> matches;
final List<HighlightDocumentLine> lines;
final List<List<HighlightSearchMatch>> lineMatches;
final List<int> 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<InlineSpan> buildAllSpans(BuildContext context) {
final spans = <InlineSpan>[];
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<InlineSpan> buildSpansForLine(BuildContext context, int lineIndex) {
final line = lines[lineIndex];
final matchesForLine = lineMatches[lineIndex];
if (matchesForLine.isEmpty) {
return _plainLineSpans(line);
}
final spans = <InlineSpan>[];
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<InlineSpan> _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<HighlightStyledSegment> 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 <Node>[];
final theme = Theme.brightnessOf(context) == Brightness.light ? atomOneLightTheme : atomOneDarkTheme;
List<HighlightStyledSegment> convert(List<Node> nodes, [TextStyle? inheritedStyle]) {
final spans = <HighlightStyledSegment>[];
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<HighlightSearchMatch> 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 = <HighlightSearchMatch>[];
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<HighlightDocumentLine> buildHighlightDocumentLines(List<HighlightStyledSegment> segments) {
final lines = <HighlightDocumentLine>[];
final currentSegments = <HighlightStyledSegment>[];
var lineStart = 0;
var offset = 0;
var lineNumber = 0;
void pushLine() {
lines.add(HighlightDocumentLine(
index: lineNumber++,
start: lineStart,
end: offset,
segments: List<HighlightStyledSegment>.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<InlineSpan> spans, String value, TextStyle? textStyle) {
if (value.isEmpty) {
return;
}
spans.add(TextSpan(text: value, style: textStyle));
}
List<List<HighlightSearchMatch>> _groupMatchesByLine(
List<HighlightDocumentLine> lines,
List<HighlightSearchMatch> matches,
) {
final grouped = List.generate(lines.length, (_) => <HighlightSearchMatch>[]);
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<int> _buildMatchLineIndexes(List<List<HighlightSearchMatch>> 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<HighlightStyledSegment> 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);
}

View File

@@ -41,8 +41,13 @@ class SearchTextController extends ValueNotifier<SearchSettings> 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;
}
}

View File

@@ -49,10 +49,10 @@ class SearchConditionsState extends State<SearchConditions> {
'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;

View File

@@ -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 {

View File

@@ -155,7 +155,7 @@ class ContentTypeState extends State<ContentTypeSelect> {
@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,

View File

@@ -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<SelectableText>(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 <InlineSpan>[]).whereType<WidgetSpan>(), 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<SelectableText>(find.byType(SelectableText));
expect(selectable.contextMenuBuilder, same(menuBuilder));
await _disposeController(tester, controller);
});
});
}
Future<void> _disposeController(WidgetTester tester, SearchTextController controller) async {
controller.closeSearch();
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
controller.dispose();
}
Iterable<TextSpan> _flattenTextSpans(InlineSpan span) sync* {
if (span is! TextSpan) {
return;
}
yield span;
for (final child in span.children ?? const <InlineSpan>[]) {
yield* _flattenTextSpans(child);
}
}