tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 960203522e65193c8376c2bad349b87a69247cdf
parent cb8e73febc93037de0395400c98226aa2245e85e
Author: Harrison Oglesby <oglesby.harrison@gmail.com>
Date:   Wed, 29 Oct 2025 17:38:05 +0000

Bug 1991818 - Add highlighting to Settings Search query matching text r=android-reviewers,petru

Differential Revision: https://phabricator.services.mozilla.com/D269681

Diffstat:
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchResultItem.kt | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchScreen.kt | 1+
Amobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/settingssearch/TextHighlightingTest.kt | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 186 insertions(+), 3 deletions(-)

diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchResultItem.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchResultItem.kt @@ -12,7 +12,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -23,13 +28,35 @@ import org.mozilla.fenix.theme.FirefoxTheme * Composable for the settings search result item. * * @param item [SettingsSearchItem] to display. + * @param query Query to highlight in the title. * @param onClick Callback for when the item is clicked. */ @Composable fun SettingsSearchResultItem( item: SettingsSearchItem, + query: String, onClick: () -> Unit, ) { + val defaultSpanStyle = SpanStyle( + fontWeight = FontWeight.Bold, + background = FirefoxTheme.colors.layer3, + ) + + val displayTitle = remember(item.title, query) { + highlightQueryMatchingText( + text = item.title, + query = query, + highlight = defaultSpanStyle, + ) + } + val displaySummary = remember(item.title, query) { + highlightQueryMatchingText( + text = item.summary, + query = query, + highlight = defaultSpanStyle, + ) + } + Column( modifier = Modifier .fillMaxWidth() @@ -46,13 +73,13 @@ fun SettingsSearchResultItem( } Text( - text = item.title, + text = displayTitle, style = FirefoxTheme.typography.subtitle1, color = FirefoxTheme.colors.textPrimary, ) - if (item.summary.isNotBlank()) { + if (displaySummary.isNotBlank()) { Text( - text = item.summary, + text = displaySummary, style = FirefoxTheme.typography.caption, color = FirefoxTheme.colors.textSecondary, modifier = Modifier.padding(top = 4.dp), @@ -63,6 +90,53 @@ fun SettingsSearchResultItem( } } +/** + * Highlights the query matching text. + * + * @param text Text to highlight. + * @param query Query to highlight. + * @param highlight Highlight style. + */ +internal fun highlightQueryMatchingText( + text: String, + query: String, + highlight: SpanStyle, +): AnnotatedString { + val tokens = query.trim().split(Regex("\\s+")).filter { it.isNotBlank() } + if (tokens.isEmpty()) return AnnotatedString(text) + + // Build one regex that matches *any* token — even inside words + // For example: "data privacy" → matches "metadata", "data-saving", "privacy-aware" + val pattern = tokens.joinToString("|") { Regex.escape(it) } + val regex = Regex(pattern, RegexOption.IGNORE_CASE) + + // Find all match ranges + val matches = regex.findAll(text).map { it.range }.toList() + if (matches.isEmpty()) return AnnotatedString(text) + + // Merge overlapping or adjacent ranges + val merged = matches.sortedBy { it.first }.fold(mutableListOf<IntRange>()) { acc, range -> + if (acc.isEmpty()) { + acc += range + } else { + val last = acc.last() + if (range.first <= last.last + 1) { + acc[acc.lastIndex] = (last.first..maxOf(last.last, range.last)) + } else { + acc += range + } + } + acc + } + + return buildAnnotatedString { + append(text) + merged.forEach { range -> + addStyle(highlight, range.first, range.last + 1) + } + } +} + private class SettingsSearchResultItemParameterProvider : PreviewParameterProvider<SettingsSearchItem> { override val values: Sequence<SettingsSearchItem> get() = sequenceOf( @@ -94,6 +168,7 @@ private fun SettingsSearchResultItemFullPreview( FirefoxTheme { SettingsSearchResultItem( item = item, + "a", onClick = {}, ) } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchScreen.kt @@ -71,6 +71,7 @@ fun SettingsSearchScreen( } SettingsSearchResultItem( item = settingsSearchItem, + query = state.searchQuery, onClick = { store.dispatch( SettingsSearchAction.ResultItemClicked( diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/settingssearch/TextHighlightingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/settingssearch/TextHighlightingTest.kt @@ -0,0 +1,107 @@ +package org.mozilla.fenix.settings.settingssearch + +import android.util.Log +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class TextHighlightingTest { + private val highlightStyle = SpanStyle(color = Color.Red, fontWeight = FontWeight.Bold) + + @Test + fun `test single word query highlights correctly`() { + val text = "Set your search engine" + val query = "search" + val annotatedString = highlightQueryMatchingText(text, query, highlightStyle) + + val spanStyles = annotatedString.spanStyles + assert(spanStyles.size == 1) + assert(spanStyles.first().start == 9) + assert(spanStyles.first().end == 15) + assert(spanStyles.first().item == highlightStyle) + } + + @Test + fun `test case-insensitive query highlights correctly`() { + val text = "Set Your Search Engine" + val query = "search" + val annotatedString = highlightQueryMatchingText(text, query, highlightStyle) + + val spanStyles = annotatedString.spanStyles + assert(spanStyles.size == 1) + assert(spanStyles.first().start == 9) + assert(spanStyles.first().end == 15) + } + + @Test + fun `test query with extra whitespace is handled`() { + val text = "Set your search engine" + val query = " search " + val annotatedString = highlightQueryMatchingText(text, query, highlightStyle) + + val spanStyles = annotatedString.spanStyles + assert(spanStyles.size == 1) + assert(spanStyles.first().start == 9) + assert(spanStyles.first().end == 15) + } + + @Test + fun `test overlapping matches are merged into one span`() { + val text = "datadata" + val query = "data" + val annotatedString = highlightQueryMatchingText(text, query, highlightStyle) + + val spanStyles = annotatedString.spanStyles + assert(spanStyles.size == 1) + assert(spanStyles.first().start == 0) + assert(spanStyles.first().end == 8) + } + + @Test + fun `test no matches returns unstyled string`() { + val text = "Set your search engine" + val query = "firefox" + val annotatedString = highlightQueryMatchingText(text, query, highlightStyle) + + assert(annotatedString.spanStyles.isEmpty()) + assert(annotatedString.text == text) + } + + @Test + fun `test empty query returns unstyled string`() { + val text = "Set your search engine" + val query = "" + val annotatedString = highlightQueryMatchingText(text, query, highlightStyle) + + assert(annotatedString.spanStyles.isEmpty()) + assert(annotatedString.text == text) + } + + @Test + fun `test empty text returns empty annotated string`() { + val text = "" + val query = "search" + val annotatedString = highlightQueryMatchingText(text, query, highlightStyle) + + assert(annotatedString.spanStyles.isEmpty()) + assert(annotatedString.text.isEmpty()) + } + + @Test + fun `test partial word matching`() { + val text = "Enable data privacy features" + val query = "priv" + val annotatedString = highlightQueryMatchingText(text, query, highlightStyle) + + val spanStyles = annotatedString.spanStyles + assert(spanStyles.size == 1) + assert(spanStyles.first().start == 12) + assert(spanStyles.first().end == 16) + } +}