mirror of
https://github.com/wanghongenpin/proxypin.git
synced 2026-04-13 20:43:31 +08:00
feat: enhance HighlightTextWidget with language support and refactor highlighting logic (#721)
This commit is contained in:
@@ -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, // 高亮项在视图中的位置
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
354
lib/ui/component/search/highlight_text_document.dart
Normal file
354
lib/ui/component/search/highlight_text_document.dart
Normal 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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
145
test/highlight_text_test.dart
Normal file
145
test/highlight_text_test.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user