commit 931064cb72b2f552fcd69d7c4fb1deb946928107
parent a3f5f005f6b53636bbc34ed2bdb1590d1c177394
Author: Harrison Oglesby <oglesby.harrison@gmail.com>
Date: Mon, 17 Nov 2025 22:38:29 +0000
Bug 1998028 - Part 3: Fix text highlighting for Settings Search r=android-reviewers,petru
Differential Revision: https://phabricator.services.mozilla.com/D272552
Diffstat:
2 files changed, 58 insertions(+), 54 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
@@ -103,7 +103,8 @@ internal fun shouldShowSummary(
}
/**
- * Highlights the query matching text.
+ * Highlights the query matching text. Only the first instance of the matching text.
+ * Works with even with mismatched capitalization.
*
* @param text Text to highlight.
* @param query Query to highlight.
@@ -114,36 +115,24 @@ internal fun highlightQueryMatchingText(
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)
+ val trimmedQuery = query.trim()
+ if (trimmedQuery.isBlank()) {
+ return AnnotatedString(text)
+ }
- // Find all match ranges
- val matches = regex.findAll(text).map { it.range }.toList()
- if (matches.isEmpty()) return AnnotatedString(text)
+ var match = Regex.escape(trimmedQuery).toRegex(RegexOption.IGNORE_CASE).find(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
- }
+ if (match == null) {
+ val tokens = trimmedQuery.split(Regex("\\s+")).filter { it.isNotBlank() }
+ if (tokens.isNotEmpty()) {
+ val pattern = tokens.joinToString("|") { Regex.escape(it) }
+ match = Regex(pattern, RegexOption.IGNORE_CASE).find(text)
}
- acc
}
return buildAnnotatedString {
append(text)
- merged.forEach { range ->
+ match?.range?.let { range ->
addStyle(highlight, range.first, range.last + 1)
}
}
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
@@ -3,99 +3,114 @@ package org.mozilla.fenix.settings.settingssearch
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
+import org.junit.Assert.assertEquals
import org.junit.Test
class TextHighlightingTest {
private val highlightStyle = SpanStyle(color = Color.Red, fontWeight = FontWeight.Bold)
@Test
- fun `test single word query highlights correctly`() {
+ fun `GIVEN a query and a text with matching text THEN it highlights the query`() {
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)
+ assertEquals(1, spanStyles.size)
+ assertEquals(9, spanStyles.first().start)
+ assertEquals(15, spanStyles.first().end)
+ assertEquals(highlightStyle, spanStyles.first().item)
}
@Test
- fun `test case-insensitive query highlights correctly`() {
+ fun `GIVEN a query and a text with matching text with mismatching capitalization THEN it highlights the query`() {
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)
+ assertEquals(1, spanStyles.size)
+ assertEquals(9, spanStyles.first().start)
+ assertEquals(15, spanStyles.first().end)
}
@Test
- fun `test query with extra whitespace is handled`() {
+ fun `GIVEN a query with leading and trailing whitespace THEN it handles the whitespace`() {
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)
+ assertEquals(1, spanStyles.size)
+ assertEquals(9, spanStyles.first().start)
+ assertEquals(15, spanStyles.first().end)
}
@Test
- fun `test overlapping matches are merged into one span`() {
+ fun `GIVEN a query WHEN there are multiple matches THEN it highlights the first match`() {
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)
+ assertEquals(1, spanStyles.size)
+ assertEquals(0, spanStyles.first().start)
+ assertEquals(4, spanStyles.first().end)
+ assertEquals(highlightStyle, spanStyles.first().item)
}
@Test
- fun `test no matches returns unstyled string`() {
+ fun `GIVEN a query WHEN there are no matches THEN it returns an unstyled AnnotatedString`() {
val text = "Set your search engine"
val query = "firefox"
val annotatedString = highlightQueryMatchingText(text, query, highlightStyle)
- assert(annotatedString.spanStyles.isEmpty())
- assert(annotatedString.text == text)
+ assertEquals(text, annotatedString.text)
+ assertEquals(true, annotatedString.spanStyles.isEmpty())
}
@Test
- fun `test empty query returns unstyled string`() {
+ fun `GIVEN an empty query THEN it returns an unstyled AnnotatedString`() {
val text = "Set your search engine"
val query = ""
val annotatedString = highlightQueryMatchingText(text, query, highlightStyle)
- assert(annotatedString.spanStyles.isEmpty())
- assert(annotatedString.text == text)
+ assertEquals(text, annotatedString.text)
+ assertEquals(true, annotatedString.spanStyles.isEmpty())
}
@Test
- fun `test empty text returns empty annotated string`() {
+ fun `GIVEN an empty text THEN it returns an empty AnnotatedString`() {
val text = ""
val query = "search"
val annotatedString = highlightQueryMatchingText(text, query, highlightStyle)
- assert(annotatedString.spanStyles.isEmpty())
- assert(annotatedString.text.isEmpty())
+ assertEquals(true, annotatedString.text.isEmpty())
+ assertEquals(true, annotatedString.spanStyles.isEmpty())
}
@Test
- fun `test partial word matching`() {
+ fun `GIVEN a partial word query THEN it highlights the matching partial word`() {
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)
+ assertEquals(1, spanStyles.size)
+ assertEquals(12, spanStyles.first().start)
+ assertEquals(16, spanStyles.first().end)
+ }
+
+ @Test
+ fun `GIVEN a multi-word query THEN it highlights all words in the query`() {
+ val text = "Enable data privacy features"
+ val query = "data privacy"
+ val annotatedString = highlightQueryMatchingText(text, query, highlightStyle)
+
+ val spanStyles = annotatedString.spanStyles
+ assertEquals(1, spanStyles.size)
+ assertEquals(7, spanStyles.first().start)
+ assertEquals(19, spanStyles.first().end)
+ assertEquals("data privacy", annotatedString.substring(7, 19))
}
}