diff --git a/app/lib/features/geckoview/features/browser/domain/providers.dart b/app/lib/features/geckoview/features/browser/domain/providers.dart index 5b254c50..39e941b6 100644 --- a/app/lib/features/geckoview/features/browser/domain/providers.dart +++ b/app/lib/features/geckoview/features/browser/domain/providers.dart @@ -321,7 +321,8 @@ EquatableValue> filteredTabPreviews( icon: null, url: tab.cleanUrl ?? availableTabStates.value[tab.id]!.url, highlightedUrl: tab.url, - content: tab.extractedContent ?? tab.fullContent, + extractedContent: tab.extractedContent, + fullContent: tab.fullContent, sourceSearchQuery: tabSearchResults.query, ); }) diff --git a/app/lib/features/geckoview/features/history/presentation/screens/history.dart b/app/lib/features/geckoview/features/history/presentation/screens/history.dart index 5be24eea..f21eac3e 100644 --- a/app/lib/features/geckoview/features/history/presentation/screens/history.dart +++ b/app/lib/features/geckoview/features/history/presentation/screens/history.dart @@ -98,7 +98,7 @@ class Section extends MultiSliver { VisitType.download => p.basename(title), _ => title, }, - maxLines: 3, + maxLines: 2, overflow: TextOverflow.ellipsis, ), ), diff --git a/app/lib/features/geckoview/features/search/domain/entities/tab_preview.dart b/app/lib/features/geckoview/features/search/domain/entities/tab_preview.dart index 745197b1..807bb87f 100644 --- a/app/lib/features/geckoview/features/search/domain/entities/tab_preview.dart +++ b/app/lib/features/geckoview/features/search/domain/entities/tab_preview.dart @@ -30,7 +30,8 @@ class TabPreview with FastEquatable { final Uri url; final String? highlightedUrl; - final String? content; + final String? extractedContent; + final String? fullContent; final String? sourceSearchQuery; @@ -41,7 +42,8 @@ class TabPreview with FastEquatable { required this.icon, required this.url, required this.highlightedUrl, - required this.content, + required this.extractedContent, + required this.fullContent, required this.sourceSearchQuery, }); @@ -53,7 +55,8 @@ class TabPreview with FastEquatable { icon, url, highlightedUrl, - content, + extractedContent, + fullContent, sourceSearchQuery, ]; } diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart index b77c5e3d..d9dc9145 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart @@ -19,7 +19,6 @@ */ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -35,6 +34,7 @@ import 'package:weblibre/features/web_feed/extensions/feed_article.dart'; import 'package:weblibre/presentation/hooks/listenable_callback.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; +import 'package:weblibre/utils/text_highlight.dart'; class FeedSearch extends HookConsumerWidget { static const _matchPrefix = '***'; @@ -116,39 +116,47 @@ class FeedSearch extends HookConsumerWidget { ], iconSize: 24.0), ), title: (titleHighlight.isNotEmpty) - ? MarkdownBody( - data: titleHighlight!, - styleSheet: MarkdownStyleSheet( - p: Theme.of(context).textTheme.titleMedium - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface, - ), + ? Text.rich( + buildHighlightedText( + titleHighlight!, + Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + _matchPrefix, + _matchSuffix, ), ) : Text( article.displayTitle, style: theme.textTheme.titleMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (searchSnippet.isNotEmpty) - MarkdownBody( - data: searchSnippet!, - styleSheet: MarkdownStyleSheet( - p: Theme.of(context).textTheme.bodyMedium?.copyWith( + Text.rich( + buildHighlightedText( + searchSnippet!, + Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of( context, ).colorScheme.onSurfaceVariant, ), - a: Theme.of(context).textTheme.bodyMedium?.copyWith( + Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of( context, ).colorScheme.onSurfaceVariant, - decoration: TextDecoration.none, + fontWeight: FontWeight.bold, ), + _matchPrefix, + _matchSuffix, + normalizeWhitespaces: true, ), ) else diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart index fcfc796b..603bd6fd 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart @@ -107,7 +107,11 @@ class HistorySuggestions extends HookConsumerWidget { const Icon(MdiIcons.web, size: 24), ), title: suggestion.title.mapNotNull( - (title) => Text(title), + (title) => Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), ), subtitle: uri.mapNotNull((uri) => UriBreadcrumb(uri: uri)) ?? diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart index 7581f7ff..dec2d60e 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart @@ -24,7 +24,6 @@ import 'package:fast_equatable/fast_equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:sliver_tools/sliver_tools.dart'; @@ -43,6 +42,7 @@ import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/c import 'package:weblibre/presentation/hooks/listenable_callback.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; +import 'package:weblibre/utils/text_highlight.dart'; class TabSearch extends HookConsumerWidget { static const _matchPrefix = '***'; @@ -171,11 +171,15 @@ class TabSearch extends HookConsumerWidget { itemBuilder: (context, index) { final result = filteredTabs[index]; + final content = + (result.extractedContent?.contains(_matchPrefix) == true) + ? result.extractedContent + : result.fullContent; + final titleHasMatch = result.title.contains(_matchPrefix); final urlHasMatch = result.highlightedUrl?.contains(_matchPrefix) ?? false; - final bodyHasMatch = - result.content?.contains(_matchPrefix) ?? false; + final bodyHasMatch = content?.contains(_matchPrefix) ?? false; return ListTile( leading: RepaintBoundary( @@ -187,30 +191,35 @@ class TabSearch extends HookConsumerWidget { UrlIcon([result.url], iconSize: 24), ), title: result.title.mapNotNull( - (title) => MarkdownBody( - data: title, - styleSheet: MarkdownStyleSheet( - p: Theme.of(context).textTheme.bodyLarge?.copyWith( + (title) => Text.rich( + buildHighlightedText( + title, + Theme.of(context).textTheme.bodyLarge?.copyWith( color: Theme.of(context).colorScheme.onSurface, ), + Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + _matchPrefix, + _matchSuffix, ), ), ), subtitle: (bodyHasMatch || (urlHasMatch && !titleHasMatch)) - ? MarkdownBody( - data: - (bodyHasMatch - ? result.content! - : result.highlightedUrl!) - .replaceAll(RegExp(r'\s+'), ' '), - styleSheet: MarkdownStyleSheet( - p: Theme.of(context).textTheme.bodyMedium?.copyWith( + ? Text.rich( + buildHighlightedText( + (bodyHasMatch ? content! : result.highlightedUrl!), + Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), - a: Theme.of(context).textTheme.bodyMedium?.copyWith( + Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, - decoration: TextDecoration.none, + fontWeight: FontWeight.bold, ), + _matchPrefix, + _matchSuffix, + normalizeWhitespaces: true, ), ) : UriBreadcrumb(uri: result.url), diff --git a/app/lib/features/web_feed/presentation/widgets/feed_article_card.dart b/app/lib/features/web_feed/presentation/widgets/feed_article_card.dart index 358a064c..8189bdc9 100644 --- a/app/lib/features/web_feed/presentation/widgets/feed_article_card.dart +++ b/app/lib/features/web_feed/presentation/widgets/feed_article_card.dart @@ -18,7 +18,6 @@ * along with this program. If not, see . */ import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:timeago/timeago.dart' as timeago; @@ -34,8 +33,12 @@ import 'package:weblibre/features/web_feed/extensions/feed_article.dart'; import 'package:weblibre/features/web_feed/presentation/widgets/authors_horizontal_list.dart'; import 'package:weblibre/features/web_feed/presentation/widgets/tags_horizontal_list.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; +import 'package:weblibre/utils/text_highlight.dart'; class FeedArticleCard extends HookConsumerWidget { + static const _matchPrefix = '***'; + static const _matchSuffix = '***'; + final FeedArticle article; const FeedArticleCard({super.key, required this.article}); @@ -86,15 +89,18 @@ class FeedArticleCard extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (titleHighlight.isNotEmpty) - MarkdownBody( - data: titleHighlight!, - styleSheet: MarkdownStyleSheet( - p: Theme.of(context).textTheme.titleMedium - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface, - ), + Text.rich( + buildHighlightedText( + titleHighlight!, + Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + _matchPrefix, + _matchSuffix, ), ), if (titleHighlight.isEmpty) @@ -103,22 +109,23 @@ class FeedArticleCard extends HookConsumerWidget { style: theme.textTheme.titleMedium, ), if (searchSnippet.isNotEmpty) - MarkdownBody( - data: searchSnippet!, - styleSheet: MarkdownStyleSheet( - p: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - a: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - decoration: TextDecoration.none, - ), + Text.rich( + buildHighlightedText( + searchSnippet!, + Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + _matchPrefix, + _matchSuffix, + normalizeWhitespaces: true, ), ), if (searchSnippet.isEmpty && diff --git a/app/lib/utils/text_highlight.dart b/app/lib/utils/text_highlight.dart new file mode 100644 index 00000000..4f4a3e9e --- /dev/null +++ b/app/lib/utils/text_highlight.dart @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2024-2025 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; + +/// Builds a [TextSpan] with highlighted sections based on prefix/suffix markers. +/// +/// This function parses text that contains highlight markers (e.g., from FTS5 +/// search results) and creates a [TextSpan] with different styles for regular +/// and highlighted text. +/// +/// Example: +/// ```dart +/// final span = buildHighlightedText( +/// 'Hello ***world***!', +/// baseStyle, +/// highlightStyle, +/// '***', +/// '***', +/// ); +/// // Results in: "Hello " (base) + "world" (highlighted) + "!" (base) +/// ``` +/// +/// [text] The text to parse for highlights +/// [baseStyle] Style for non-highlighted text +/// [highlightStyle] Style for highlighted text +/// [matchPrefix] Marker that indicates the start of a highlight +/// [matchSuffix] Marker that indicates the end of a highlight +TextSpan buildHighlightedText( + String text, + TextStyle? baseStyle, + TextStyle? highlightStyle, + String matchPrefix, + String matchSuffix, { + bool normalizeWhitespaces = false, +}) { + final spans = []; + var currentIndex = 0; + + if (normalizeWhitespaces) { + // ignore: parameter_assignments + text = text.replaceAll(RegExp(r'\s+'), ' '); + } + + while (currentIndex < text.length) { + final prefixIndex = text.indexOf(matchPrefix, currentIndex); + if (prefixIndex == -1) { + spans.add(TextSpan(text: text.substring(currentIndex), style: baseStyle)); + break; + } + + if (prefixIndex > currentIndex) { + spans.add( + TextSpan( + text: text.substring(currentIndex, prefixIndex), + style: baseStyle, + ), + ); + } + + final suffixIndex = text.indexOf( + matchSuffix, + prefixIndex + matchPrefix.length, + ); + if (suffixIndex == -1) { + // No closing marker - highlight everything from prefix to end + final highlightedText = text.substring(prefixIndex + matchPrefix.length); + spans.add(TextSpan(text: highlightedText, style: highlightStyle)); + break; + } + + final highlightedText = text.substring( + prefixIndex + matchPrefix.length, + suffixIndex, + ); + spans.add(TextSpan(text: highlightedText, style: highlightStyle)); + + currentIndex = suffixIndex + matchSuffix.length; + } + + return TextSpan(children: spans); +} diff --git a/app/test/utils/text_highlight_test.dart b/app/test/utils/text_highlight_test.dart new file mode 100644 index 00000000..6579e399 --- /dev/null +++ b/app/test/utils/text_highlight_test.dart @@ -0,0 +1,290 @@ +/* + * Copyright (c) 2024-2025 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/utils/text_highlight.dart'; + +void main() { + group('buildHighlightedText', () { + const baseStyle = TextStyle(color: Colors.black); + const highlightStyle = TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + ); + const matchPrefix = '***'; + const matchSuffix = '***'; + + test('returns plain text when no highlights present', () { + final result = buildHighlightedText( + 'Hello world', + baseStyle, + highlightStyle, + matchPrefix, + matchSuffix, + ); + + expect(result.children?.length, 1); + final span = result.children![0] as TextSpan; + expect(span.text, 'Hello world'); + expect(span.style, baseStyle); + }); + + test('highlights single match in middle of text', () { + final result = buildHighlightedText( + 'Hello ***world***!', + baseStyle, + highlightStyle, + matchPrefix, + matchSuffix, + ); + + expect(result.children?.length, 3); + + final span1 = result.children![0] as TextSpan; + expect(span1.text, 'Hello '); + expect(span1.style, baseStyle); + + final span2 = result.children![1] as TextSpan; + expect(span2.text, 'world'); + expect(span2.style, highlightStyle); + + final span3 = result.children![2] as TextSpan; + expect(span3.text, '!'); + expect(span3.style, baseStyle); + }); + + test('highlights multiple matches', () { + final result = buildHighlightedText( + 'The ***quick*** brown ***fox***', + baseStyle, + highlightStyle, + matchPrefix, + matchSuffix, + ); + + expect(result.children?.length, 4); + + expect((result.children![0] as TextSpan).text, 'The '); + expect((result.children![0] as TextSpan).style, baseStyle); + + expect((result.children![1] as TextSpan).text, 'quick'); + expect((result.children![1] as TextSpan).style, highlightStyle); + + expect((result.children![2] as TextSpan).text, ' brown '); + expect((result.children![2] as TextSpan).style, baseStyle); + + expect((result.children![3] as TextSpan).text, 'fox'); + expect((result.children![3] as TextSpan).style, highlightStyle); + }); + + test('handles highlight at start of text', () { + final result = buildHighlightedText( + '***Hello*** world', + baseStyle, + highlightStyle, + matchPrefix, + matchSuffix, + ); + + expect(result.children?.length, 2); + + final span1 = result.children![0] as TextSpan; + expect(span1.text, 'Hello'); + expect(span1.style, highlightStyle); + + final span2 = result.children![1] as TextSpan; + expect(span2.text, ' world'); + expect(span2.style, baseStyle); + }); + + test('handles highlight at end of text', () { + final result = buildHighlightedText( + 'Hello ***world***', + baseStyle, + highlightStyle, + matchPrefix, + matchSuffix, + ); + + expect(result.children?.length, 2); + + final span1 = result.children![0] as TextSpan; + expect(span1.text, 'Hello '); + expect(span1.style, baseStyle); + + final span2 = result.children![1] as TextSpan; + expect(span2.text, 'world'); + expect(span2.style, highlightStyle); + }); + + test('handles entire text highlighted', () { + final result = buildHighlightedText( + '***Hello world***', + baseStyle, + highlightStyle, + matchPrefix, + matchSuffix, + ); + + expect(result.children?.length, 1); + + final span = result.children![0] as TextSpan; + expect(span.text, 'Hello world'); + expect(span.style, highlightStyle); + }); + + test('handles consecutive highlights', () { + final result = buildHighlightedText( + '***hello******world***', + baseStyle, + highlightStyle, + matchPrefix, + matchSuffix, + ); + + expect(result.children?.length, 2); + + expect((result.children![0] as TextSpan).text, 'hello'); + expect((result.children![0] as TextSpan).style, highlightStyle); + + expect((result.children![1] as TextSpan).text, 'world'); + expect((result.children![1] as TextSpan).style, highlightStyle); + }); + + test('handles empty highlight', () { + final result = buildHighlightedText( + 'Hello ******world', + baseStyle, + highlightStyle, + matchPrefix, + matchSuffix, + ); + + expect(result.children?.length, 3); + + expect((result.children![0] as TextSpan).text, 'Hello '); + expect((result.children![1] as TextSpan).text, ''); + expect((result.children![2] as TextSpan).text, 'world'); + }); + + test('handles unclosed prefix marker', () { + final result = buildHighlightedText( + 'Hello ***world', + baseStyle, + highlightStyle, + matchPrefix, + matchSuffix, + ); + + // When suffix not found, highlight everything after prefix to end + expect(result.children?.length, 2); + + expect((result.children![0] as TextSpan).text, 'Hello '); + expect((result.children![0] as TextSpan).style, baseStyle); + expect((result.children![1] as TextSpan).text, 'world'); + expect((result.children![1] as TextSpan).style, highlightStyle); + }); + + test('handles missing suffix marker', () { + final result = buildHighlightedText( + '***Hello', + baseStyle, + highlightStyle, + matchPrefix, + matchSuffix, + ); + + expect(result.children?.length, 1); + + final span = result.children![0] as TextSpan; + expect(span.text, 'Hello'); + expect(span.style, highlightStyle); + }); + + test('handles empty string', () { + final result = buildHighlightedText( + '', + baseStyle, + highlightStyle, + matchPrefix, + matchSuffix, + ); + + expect(result.children?.length, 0); + }); + + test('handles different prefix and suffix markers', () { + final result = buildHighlightedText( + 'Hello world!', + baseStyle, + highlightStyle, + '', + '', + ); + + expect(result.children?.length, 3); + + expect((result.children![0] as TextSpan).text, 'Hello '); + expect((result.children![1] as TextSpan).text, 'world'); + expect((result.children![1] as TextSpan).style, highlightStyle); + expect((result.children![2] as TextSpan).text, '!'); + }); + + // Note: Nested markers behavior is undefined and not a real-world FTS5 scenario + // This test is skipped as the exact parsing behavior for nested markers + // is implementation-specific and not guaranteed + + test('handles null styles', () { + final result = buildHighlightedText( + 'Hello ***world***!', + null, + null, + matchPrefix, + matchSuffix, + ); + + expect(result.children?.length, 3); + + expect((result.children![0] as TextSpan).style, null); + expect((result.children![1] as TextSpan).style, null); + expect((result.children![2] as TextSpan).style, null); + }); + + test('real-world FTS5 example', () { + final result = buildHighlightedText( + 'Mozilla Developer Network (***MDN***) Web Docs', + baseStyle, + highlightStyle, + matchPrefix, + matchSuffix, + ); + + expect(result.children?.length, 3); + + expect( + (result.children![0] as TextSpan).text, + 'Mozilla Developer Network (', + ); + expect((result.children![1] as TextSpan).text, 'MDN'); + expect((result.children![1] as TextSpan).style, highlightStyle); + expect((result.children![2] as TextSpan).text, ') Web Docs'); + }); + }); +}