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 baseStyle = segment.style ?? const TextStyle(); final highlightedStyle = baseStyle.copyWith( backgroundColor: isCurrentMatch ? colorScheme.primary : colorScheme.inversePrimary, color: isCurrentMatch ? colorScheme.onPrimary : baseStyle.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: '', 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))]; } 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); }