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:
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)
+ }
+}